diff --git a/packages/ipc/src/ipc-client.ts b/packages/ipc/src/ipc-client.ts index da96ab90f32..d374cb186a3 100644 --- a/packages/ipc/src/ipc-client.ts +++ b/packages/ipc/src/ipc-client.ts @@ -108,6 +108,13 @@ export class IpcClient extends EventEmitter { }) } + public deleteQueuedMessage(messageId: string) { + this.sendCommand({ + commandName: TaskCommandName.DeleteQueuedMessage, + data: messageId, + }) + } + public sendMessage(message: IpcMessage) { ipc.of[this._id]?.emit("message", message) } diff --git a/packages/types/src/__tests__/ipc.test.ts b/packages/types/src/__tests__/ipc.test.ts index 856b3f2cc18..a843354a559 100644 --- a/packages/types/src/__tests__/ipc.test.ts +++ b/packages/types/src/__tests__/ipc.test.ts @@ -6,8 +6,19 @@ describe("IPC Types", () => { expect(TaskCommandName.ResumeTask).toBe("ResumeTask") }) + it("should include DeleteQueuedMessage command", () => { + expect(TaskCommandName.DeleteQueuedMessage).toBe("DeleteQueuedMessage") + }) + it("should have all expected task commands", () => { - const expectedCommands = ["StartNewTask", "CancelTask", "CloseTask", "ResumeTask"] + const expectedCommands = [ + "StartNewTask", + "CancelTask", + "CloseTask", + "ResumeTask", + "SendMessage", + "DeleteQueuedMessage", + ] const actualCommands = Object.values(TaskCommandName) expectedCommands.forEach((command) => { @@ -70,5 +81,40 @@ describe("IPC Types", () => { const result = taskCommandSchema.safeParse(invalidCommand) expect(result.success).toBe(false) }) + + it("should validate DeleteQueuedMessage command with messageId", () => { + const command = { + commandName: TaskCommandName.DeleteQueuedMessage, + data: "msg-abc-123", + } + + const result = taskCommandSchema.safeParse(command) + expect(result.success).toBe(true) + + if (result.success && result.data.commandName === TaskCommandName.DeleteQueuedMessage) { + expect(result.data.commandName).toBe("DeleteQueuedMessage") + expect(result.data.data).toBe("msg-abc-123") + } + }) + + it("should reject DeleteQueuedMessage command with invalid data", () => { + const invalidCommand = { + commandName: TaskCommandName.DeleteQueuedMessage, + data: 123, // Should be string + } + + const result = taskCommandSchema.safeParse(invalidCommand) + expect(result.success).toBe(false) + }) + + it("should reject DeleteQueuedMessage command without data", () => { + const invalidCommand = { + commandName: TaskCommandName.DeleteQueuedMessage, + // Missing data field + } + + const result = taskCommandSchema.safeParse(invalidCommand) + expect(result.success).toBe(false) + }) }) }) diff --git a/packages/types/src/ipc.ts b/packages/types/src/ipc.ts index 90a1478a4db..fea040af0b6 100644 --- a/packages/types/src/ipc.ts +++ b/packages/types/src/ipc.ts @@ -49,6 +49,7 @@ export enum TaskCommandName { GetCommands = "GetCommands", GetModes = "GetModes", GetModels = "GetModels", + DeleteQueuedMessage = "DeleteQueuedMessage", } /** @@ -91,6 +92,10 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [ z.object({ commandName: z.literal(TaskCommandName.GetModels), }), + z.object({ + commandName: z.literal(TaskCommandName.DeleteQueuedMessage), + data: z.string(), // messageId + }), ]) export type TaskCommand = z.infer diff --git a/src/extension/__tests__/api-delete-queued-message.spec.ts b/src/extension/__tests__/api-delete-queued-message.spec.ts new file mode 100644 index 00000000000..6bf6014bf84 --- /dev/null +++ b/src/extension/__tests__/api-delete-queued-message.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" + +import { API } from "../api" +import { ClineProvider } from "../../core/webview/ClineProvider" + +vi.mock("vscode") +vi.mock("../../core/webview/ClineProvider") + +describe("API - DeleteQueuedMessage Command", () => { + let api: API + let mockOutputChannel: vscode.OutputChannel + let mockProvider: ClineProvider + let mockRemoveMessage: ReturnType + let mockLog: ReturnType + + beforeEach(() => { + mockOutputChannel = { + appendLine: vi.fn(), + } as unknown as vscode.OutputChannel + + mockRemoveMessage = vi.fn().mockReturnValue(true) + + mockProvider = { + context: {} as vscode.ExtensionContext, + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + getCurrentTask: vi.fn().mockReturnValue({ + messageQueueService: { + removeMessage: mockRemoveMessage, + }, + }), + viewLaunched: true, + } as unknown as ClineProvider + + mockLog = vi.fn() + + api = new API(mockOutputChannel, mockProvider, undefined, true) + ;(api as any).log = mockLog + }) + + it("should remove a queued message by id", () => { + const messageId = "msg-abc-123" + + api.deleteQueuedMessage(messageId) + + expect(mockRemoveMessage).toHaveBeenCalledWith(messageId) + expect(mockRemoveMessage).toHaveBeenCalledTimes(1) + }) + + it("should handle missing current task gracefully and log a message", () => { + ;(mockProvider.getCurrentTask as ReturnType).mockReturnValue(undefined) + + // Should not throw + expect(() => api.deleteQueuedMessage("msg-abc-123")).not.toThrow() + expect(mockLog).toHaveBeenCalledWith( + "[API#deleteQueuedMessage] no current task; ignoring delete for messageId msg-abc-123", + ) + expect(mockRemoveMessage).not.toHaveBeenCalled() + }) + + it("should handle non-existent message id gracefully", () => { + mockRemoveMessage.mockReturnValue(false) + + // Should not throw even when removeMessage returns false + expect(() => api.deleteQueuedMessage("non-existent-id")).not.toThrow() + expect(mockRemoveMessage).toHaveBeenCalledWith("non-existent-id") + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index 25c81a65896..4a66b40078d 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -150,6 +150,15 @@ export class API extends EventEmitter implements RooCodeAPI { sendResponse(RooCodeEventName.ModelsResponse, [{}]) } + break + case TaskCommandName.DeleteQueuedMessage: + this.log(`[API] DeleteQueuedMessage -> ${command.data}`) + try { + this.deleteQueuedMessage(command.data) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.log(`[API] DeleteQueuedMessage failed for messageId ${command.data}: ${errorMessage}`) + } break } }) @@ -266,6 +275,17 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) } + public deleteQueuedMessage(messageId: string) { + const currentTask = this.sidebarProvider.getCurrentTask() + + if (!currentTask) { + this.log(`[API#deleteQueuedMessage] no current task; ignoring delete for messageId ${messageId}`) + return + } + + currentTask.messageQueueService.removeMessage(messageId) + } + public async pressPrimaryButton() { await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" }) }