From 7d63bba67cd792d9ad013410a889c1fd454159ed Mon Sep 17 00:00:00 2001 From: Roomote Date: Fri, 21 Aug 2026 03:12:45 +0000 Subject: [PATCH 01/16] fix(task): preserve queued input as feedback --- apps/vscode-e2e/src/fixtures/subtasks.ts | 85 +++++++++++++++++++ apps/vscode-e2e/src/suite/subtasks.test.ts | 72 ++++++++++++++++ .../ask-queued-message-drain.spec.ts | 8 +- 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index ebfd94324e..7ce8122185 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -16,6 +16,8 @@ const SUBTASK_APPROVAL_RESTORE_CHILD_MARKER = "SUBTASK_CHILD_APPROVAL_RESTORE" const SUBTASK_XPROFILE_PARENT_MARKER = "SUBTASK_PARENT_CROSS_PROFILE" const SUBTASK_XPROFILE_SAME_CHILD_MARKER = "SUBTASK_CHILD_SAME_PROFILE" const SUBTASK_XPROFILE_DIFFERENT_CHILD_MARKER = "SUBTASK_CHILD_DIFFERENT_PROFILE" +export const SUBTASK_QUEUED_INPUT_PARENT_MARKER = "SUBTASK_PARENT_QUEUED_INPUT" +export const SUBTASK_QUEUED_INPUT_CHILD_MARKER = "SUBTASK_CHILD_QUEUED_INPUT" const SUBTASK_CHILD_PROMPT = `${SUBTASK_CHILD_MARKER}: Ask the user exactly this follow-up question: What is the square root of 81? After the user answers, complete with only the answer.` export const SUBTASK_PARENT_PROMPT = `${SUBTASK_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_CHILD_PROMPT}" Do not answer directly.` @@ -59,6 +61,14 @@ export const SUBTASK_XPROFILE_SAME_CHILD_RESULT = "Same-profile child completed" export const SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT = "Different-profile child completed" export const SUBTASK_XPROFILE_PARENT_RESULT = "Sequential cross-profile parent resumed" +const SUBTASK_QUEUED_INPUT_INITIAL_RESULT = "Child completed before queued input" +export const SUBTASK_QUEUED_INPUT_MESSAGE = "Use the queued instruction before completing." +export const SUBTASK_QUEUED_INPUT_CHILD_RESULT = "Child processed queued input" +export const SUBTASK_QUEUED_INPUT_PARENT_RESULT = "Parent resumed after queued input" +const SUBTASK_QUEUED_INPUT_CHILD_PROMPT = `${SUBTASK_QUEUED_INPUT_CHILD_MARKER}: Complete immediately with the exact result "${SUBTASK_QUEUED_INPUT_INITIAL_RESULT}".` +export const SUBTASK_QUEUED_INPUT_PARENT_PROMPT = `${SUBTASK_QUEUED_INPUT_PARENT_MARKER}: Use the new_task tool exactly once. Create an ask-mode subtask with this exact message: "${SUBTASK_QUEUED_INPUT_CHILD_PROMPT}" Do not answer directly. When the subtask returns, complete with the exact result "${SUBTASK_QUEUED_INPUT_PARENT_RESULT}".` +export const SUBTASK_QUEUED_INPUT_RESPONSE_LATENCY_MS = 2_000 + // Scheduler regression tests — exercises TaskScheduler + run() dispatch post-CodeRabbit fix. // Separate markers to avoid collisions with the other subtask fixtures. const SCHED_STANDALONE_MARKER = "SCHED_STANDALONE_INTERRUPT_RESUME" @@ -179,6 +189,81 @@ export function addSubtaskFixtures(mock: InstanceType) { }, }) + mock.addFixture({ + match: { + userMessage: new RegExp(SUBTASK_QUEUED_INPUT_PARENT_MARKER), + sequenceIndex: 0, + }, + response: { + toolCalls: [ + { + name: "new_task", + arguments: JSON.stringify({ + mode: "ask", + message: SUBTASK_QUEUED_INPUT_CHILD_PROMPT, + }), + id: "call_queued_input_parent_new_task_001", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + lastUserMessageContains(req, SUBTASK_QUEUED_INPUT_CHILD_MARKER) && + !requestContains(req, [SUBTASK_QUEUED_INPUT_PARENT_MARKER]) && + !requestContains(req, [SUBTASK_QUEUED_INPUT_MESSAGE]), + }, + streamingProfile: { ttft: SUBTASK_QUEUED_INPUT_RESPONSE_LATENCY_MS }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_QUEUED_INPUT_INITIAL_RESULT }), + id: "call_queued_input_child_initial_completion_002", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [SUBTASK_QUEUED_INPUT_CHILD_MARKER, SUBTASK_QUEUED_INPUT_MESSAGE]) && + !requestContains(req, [SUBTASK_QUEUED_INPUT_PARENT_MARKER]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_QUEUED_INPUT_CHILD_RESULT }), + id: "call_queued_input_child_revised_completion_003", + }, + ], + }, + }) + + mock.addFixture({ + match: { + predicate: (req: ChatCompletionRequest) => + requestContains(req, [ + SUBTASK_QUEUED_INPUT_PARENT_MARKER, + SUBTASK_RESULT_INJECTION, + SUBTASK_QUEUED_INPUT_CHILD_RESULT, + ]), + }, + response: { + toolCalls: [ + { + name: "attempt_completion", + arguments: JSON.stringify({ result: SUBTASK_QUEUED_INPUT_PARENT_RESULT }), + id: "call_queued_input_parent_completion_004", + }, + ], + }, + }) + mock.addFixture({ match: { userMessage: new RegExp(SUBTASK_FAST_PARENT_MARKER), diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 857c8accc5..1116ebf89c 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -27,6 +27,11 @@ import { SUBTASK_INTERRUPT_PARENT_PROMPT, SUBTASK_INTERRUPT_PARENT_RESULT, SUBTASK_PARENT_PROMPT, + SUBTASK_QUEUED_INPUT_CHILD_MARKER, + SUBTASK_QUEUED_INPUT_CHILD_RESULT, + SUBTASK_QUEUED_INPUT_MESSAGE, + SUBTASK_QUEUED_INPUT_PARENT_PROMPT, + SUBTASK_QUEUED_INPUT_PARENT_RESULT, SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT, SUBTASK_XPROFILE_PARENT_PROMPT, SUBTASK_XPROFILE_PARENT_RESULT, @@ -260,6 +265,73 @@ suite("Roo Code Subtasks", function () { } }) + test("queued input interrupts child completion before the parent resumes", async () => { + const api = globalThis.api + const says: Record = {} + + const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { + if (message.type === "say" && message.partial === false) { + says[taskId] = says[taskId] || [] + says[taskId].push(message) + } + } + + api.on(RooCodeEventName.Message, messageHandler) + + try { + const parentTaskId = await api.startNewTask({ + configuration: { + mode: "ask", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: SUBTASK_QUEUED_INPUT_PARENT_PROMPT, + }) + + let childTaskId: string | undefined + await waitFor(() => { + const current = api.getCurrentTaskStack().at(-1) + if (current && current !== parentTaskId) { + childTaskId = current + return true + } + return false + }) + + await waitForAimockRequestContaining(SUBTASK_QUEUED_INPUT_CHILD_MARKER) + + const completedParentTaskId = await waitUntilCompleted({ + api, + start: async () => { + await api.sendMessage(SUBTASK_QUEUED_INPUT_MESSAGE) + return parentTaskId + }, + }) + + assert.strictEqual(completedParentTaskId, parentTaskId) + assert.ok( + says[childTaskId!]?.some( + ({ say, text }) => + say === "completion_result" && text?.trim() === SUBTASK_QUEUED_INPUT_CHILD_RESULT, + ), + "Child should process the queued instruction before returning to its parent", + ) + assert.strictEqual( + says[parentTaskId]?.find(({ say }) => say === "completion_result")?.text?.trim(), + SUBTASK_QUEUED_INPUT_PARENT_RESULT, + "Parent should resume only after the child processes the queued instruction", + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {}) + } + }) + // Smoke: child completing normally must resume the parent task. test("child task returns to parent after normal completion", async () => { const api = globalThis.api diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index b137130174..44cbe6d06e 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -41,7 +41,7 @@ describe("Task.ask queued message drain", () => { const askPromise = task.ask("followup", "Q?", false) // Simulate webview queuing the user's selection text while the ask is pending. - ;(task as any).messageQueueService.addMessage("picked answer") + task.messageQueueService.addMessage("picked answer") const result = await askPromise expect(result.response).toBe("messageResponse") @@ -52,7 +52,7 @@ describe("Task.ask queued message drain", () => { const task = await createTask() const askPromise = task.ask("command_output", "command is still running...", false) - ;(task as any).messageQueueService.addMessage("1+1=?") + task.messageQueueService.addMessage("1+1=?") setTimeout(() => { task.approveAsk() @@ -62,8 +62,8 @@ describe("Task.ask queued message drain", () => { expect(result.response).toBe("yesButtonClicked") expect(result.text).toBeUndefined() - expect((task as any).messageQueueService.isEmpty()).toBe(false) - expect((task as any).messageQueueService.messages[0]?.text).toBe("1+1=?") + expect(task.messageQueueService.isEmpty()).toBe(false) + expect(task.messageQueueService.messages[0]?.text).toBe("1+1=?") }) it("does not consume a message already queued before a command_output ask", async () => { From e3080270dfb3adab6bd3d21a99211efcceb77cbd Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Wed, 26 Aug 2026 02:43:57 +0000 Subject: [PATCH 02/16] fix(task): safely drain queued messages into pending asks --- src/core/task/Task.ts | 13 +++++++ .../ask-queued-message-drain.spec.ts | 1 - src/core/tools/ReadFileTool.ts | 8 ++-- src/core/tools/__tests__/readFileTool.spec.ts | 38 +++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 543084571d..9306843d6c 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1284,6 +1284,19 @@ export class Task extends EventEmitter implements TaskLike { return undefined } + private drainQueuedMessageIntoAskResponse(): void { + // A synchronous auto-approval may already have resolved the ask before the + // entry queue snapshot is acted on. Never replace that resolved response. + if (this.askResponse !== undefined) { + return + } + + const message = this.messageQueueService.dequeueMessage() + if (message) { + this.handleWebviewAskResponse("messageResponse", message.text, message.images) + } + } + // Note that `partial` has three valid states true (partial message), // false (completion of partial message), undefined (individual complete // message). diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 44cbe6d06e..4a25c790c6 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -52,7 +52,6 @@ describe("Task.ask queued message drain", () => { const task = await createTask() const askPromise = task.ask("command_output", "command is still running...", false) - task.messageQueueService.addMessage("1+1=?") setTimeout(() => { task.approveAsk() diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..fa26e32630 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -292,7 +292,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { output = `IMPORTANT: File content truncated. Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. To read more: Use the read_file tool with offset=${nextOffset} and limit=${effectiveLimit}. - + ${result.content}` } else if (result.includedRanges.length > 0) { const rangeStr = result.includedRanges.map(([s, e]) => `${s}-${e}`).join(", ") @@ -320,7 +320,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { output = `IMPORTANT: File content truncated. Status: Showing lines ${startLine}-${endLine} of ${result.totalLines} total lines. To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. - + ${result.content}` } else if (result.returnedLines === 0) { output = "Note: File is empty" @@ -453,7 +453,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { filesToApprove.forEach((fr) => { updateFileResult(fr.path, { status: "approved", feedbackText: text, feedbackImages: images }) }) - } else if (response === "noButtonClicked") { + } else if (response === "noButtonClicked" || response === "messageResponse") { + // A queued conversational message resolves the ask as messageResponse; + // it is feedback, not the JSON payload used by per-file permissions. if (text) await task.say("user_feedback", text, images) task.didRejectTool = true filesToApprove.forEach((fr) => { diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6c9e177d38..e71f90b203 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -17,6 +17,7 @@ import path from "path" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" +import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, @@ -649,6 +650,43 @@ describe("ReadFileTool", () => { expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "This file contains secrets", undefined) expect(formatResponse.toolDeniedWithFeedback).toHaveBeenCalledWith("This file contains secrets") }) + + it("denies batch reads and reports queued message feedback without parsing it as permissions", async () => { + const task = Object.create(Task.prototype) as Task + Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) + Object.assign(task, createMockTask()) + const queuedImages = ["data:image/png;base64,queued"] + task.ask = vi.fn().mockResolvedValue({ + response: "messageResponse", + text: "Read a different file instead", + images: queuedImages, + }) + const fileResults = [ + { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, + { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, + ] + const updates = new Map>() + const parseSpy = vi.spyOn(JSON, "parse") + + await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { + updates.set(filePath, update) + }) + + expect(parseSpy).not.toHaveBeenCalled() + expect(task.say).toHaveBeenCalledWith("user_feedback", "Read a different file instead", queuedImages) + expect(task.didRejectTool).toBe(true) + expect(updates.get("one.ts")).toMatchObject({ + status: "denied", + feedbackText: "Read a different file instead", + feedbackImages: queuedImages, + }) + expect(updates.get("two.ts")).toMatchObject({ + status: "denied", + feedbackText: "Read a different file instead", + feedbackImages: queuedImages, + }) + parseSpy.mockRestore() + }) }) describe("output structure", () => { From 60644797e7d02721085b399147242fcaa02c96e0 Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Thu, 27 Aug 2026 01:44:13 +0000 Subject: [PATCH 03/16] fix(task): enqueue streaming input before child completion --- src/core/task/Task.ts | 13 +++++-- src/core/tools/__tests__/readFileTool.spec.ts | 16 +++++++++ .../__tests__/api-send-message.spec.ts | 36 +++++++++++++++++++ src/extension/api.ts | 9 +++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9306843d6c..bf465d8908 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -29,6 +29,7 @@ import { type ContextTruncation, type ClineMessage, type ClineSay, + type ClineSayTool, type ClineAsk, type ToolProgressStatus, type HistoryItem, @@ -1284,10 +1285,10 @@ export class Task extends EventEmitter implements TaskLike { return undefined } - private drainQueuedMessageIntoAskResponse(): void { + private drainQueuedMessageIntoAskResponse(allowResolvedAskOverride = false): void { // A synchronous auto-approval may already have resolved the ask before the // entry queue snapshot is acted on. Never replace that resolved response. - if (this.askResponse !== undefined) { + if (this.askResponse !== undefined && !allowResolvedAskOverride) { return } @@ -1471,6 +1472,14 @@ export class Task extends EventEmitter implements TaskLike { // Keep queued user messages intact during command_output asks. Those asks // are terminal flow-control, not conversational turns. const shouldDrainQueuedMessageForAsk = type !== "command_output" + let isFinishTaskAsk = false + if (type === "tool") { + try { + isFinishTaskAsk = (JSON.parse(text || "{}") as ClineSayTool).tool === "finishTask" + } catch { + // Invalid tool payloads are handled by their caller; they are not finishTask asks. + } + } const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" let queuedMessageId: string | undefined diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index e71f90b203..862126edad 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -687,6 +687,22 @@ describe("ReadFileTool", () => { }) parseSpy.mockRestore() }) + + it("denies batch reads for a queued message response without text", async () => { + const task = Object.create(Task.prototype) as Task + Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) + Object.assign(task, createMockTask()) + task.ask = vi.fn().mockResolvedValue({ response: "messageResponse", text: undefined, images: undefined }) + const fileResults = [ + { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, + { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, + ] + + await readFileTool["requestApproval"](task, fileResults, () => {}) + + expect(task.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + expect(task.didRejectTool).toBe(true) + }) }) describe("output structure", () => { diff --git a/src/extension/__tests__/api-send-message.spec.ts b/src/extension/__tests__/api-send-message.spec.ts index 23677b1218..d198919b19 100644 --- a/src/extension/__tests__/api-send-message.spec.ts +++ b/src/extension/__tests__/api-send-message.spec.ts @@ -56,6 +56,42 @@ describe("API - SendMessage Command", () => { }) }) + it("should enqueue directly when the current task is streaming", async () => { + const addMessage = vi.fn() + const messageText = "Use this before completing" + const images = ["data:image/png;base64,image1data"] + const currentTask = { + isStreaming: true, + messageQueueService: { addMessage }, + } + mockProvider.getCurrentTask = vi.fn().mockReturnValue(currentTask) + + await api.sendMessage(messageText, images) + + expect(addMessage).toHaveBeenCalledWith(messageText, images) + expect(mockPostMessageToWebview).not.toHaveBeenCalled() + }) + + it("should retain webview routing when the current task is not streaming", async () => { + const addMessage = vi.fn() + const messageText = "Answer the current ask" + const currentTask = { + isStreaming: false, + messageQueueService: { addMessage }, + } + mockProvider.getCurrentTask = vi.fn().mockReturnValue(currentTask) + + await api.sendMessage(messageText) + + expect(addMessage).not.toHaveBeenCalled() + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ + type: "invoke", + invoke: "sendMessage", + text: messageText, + images: undefined, + }) + }) + it("should handle SendMessage command with text and images", async () => { // Arrange const messageText = "Analyze this image" diff --git a/src/extension/api.ts b/src/extension/api.ts index 74ea2e7680..4ba9a6192c 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -272,6 +272,15 @@ export class API extends EventEmitter implements RooCodeAPI { public async sendMessage(text?: string, images?: string[]) { const currentTask = this.sidebarProvider.getCurrentTask() + // API callers need the returned promise to mean that sequencing-critical + // input has reached the active task. During a stream, the webview would + // only relay this message back as queueMessage asynchronously, so enqueue + // it in the extension host instead of racing task completion. + if (currentTask?.isStreaming) { + currentTask.messageQueueService.addMessage(text ?? "", images) + return + } + // In headless/sandbox flows the webview may not be launched, so routing // through invoke=sendMessage drops the message. Deliver directly to the // task ask-response channel instead. From 94f53c34c45157ff42536397edf9d2a0b66e8400 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 00:41:26 +0000 Subject: [PATCH 04/16] test(task): cover rebased queue branches --- src/core/task/Task.ts | 22 ------------- .../ask-queued-message-drain.spec.ts | 1 + src/core/tools/__tests__/readFileTool.spec.ts | 31 +++++++++++++++++-- src/eslint-suppressions.json | 2 +- 4 files changed, 31 insertions(+), 25 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index bf465d8908..543084571d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -29,7 +29,6 @@ import { type ContextTruncation, type ClineMessage, type ClineSay, - type ClineSayTool, type ClineAsk, type ToolProgressStatus, type HistoryItem, @@ -1285,19 +1284,6 @@ export class Task extends EventEmitter implements TaskLike { return undefined } - private drainQueuedMessageIntoAskResponse(allowResolvedAskOverride = false): void { - // A synchronous auto-approval may already have resolved the ask before the - // entry queue snapshot is acted on. Never replace that resolved response. - if (this.askResponse !== undefined && !allowResolvedAskOverride) { - return - } - - const message = this.messageQueueService.dequeueMessage() - if (message) { - this.handleWebviewAskResponse("messageResponse", message.text, message.images) - } - } - // Note that `partial` has three valid states true (partial message), // false (completion of partial message), undefined (individual complete // message). @@ -1472,14 +1458,6 @@ export class Task extends EventEmitter implements TaskLike { // Keep queued user messages intact during command_output asks. Those asks // are terminal flow-control, not conversational turns. const shouldDrainQueuedMessageForAsk = type !== "command_output" - let isFinishTaskAsk = false - if (type === "tool") { - try { - isFinishTaskAsk = (JSON.parse(text || "{}") as ClineSayTool).tool === "finishTask" - } catch { - // Invalid tool payloads are handled by their caller; they are not finishTask asks. - } - } const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" let queuedMessageId: string | undefined diff --git a/src/core/task/__tests__/ask-queued-message-drain.spec.ts b/src/core/task/__tests__/ask-queued-message-drain.spec.ts index 4a25c790c6..44cbe6d06e 100644 --- a/src/core/task/__tests__/ask-queued-message-drain.spec.ts +++ b/src/core/task/__tests__/ask-queued-message-drain.spec.ts @@ -52,6 +52,7 @@ describe("Task.ask queued message drain", () => { const task = await createTask() const askPromise = task.ask("command_output", "command is still running...", false) + task.messageQueueService.addMessage("1+1=?") setTimeout(() => { task.approveAsk() diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 862126edad..6adfe6c292 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -688,11 +688,11 @@ describe("ReadFileTool", () => { parseSpy.mockRestore() }) - it("denies batch reads for a queued message response without text", async () => { + it("denies batch reads without feedback text", async () => { const task = Object.create(Task.prototype) as Task Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) Object.assign(task, createMockTask()) - task.ask = vi.fn().mockResolvedValue({ response: "messageResponse", text: undefined, images: undefined }) + task.ask = vi.fn().mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) const fileResults = [ { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, @@ -703,6 +703,33 @@ describe("ReadFileTool", () => { expect(task.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) expect(task.didRejectTool).toBe(true) }) + + it("applies individual decisions for a batch read", async () => { + const task = Object.create(Task.prototype) as Task + Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) + Object.assign(task, createMockTask()) + task.ask = vi.fn().mockImplementation(async (_type, text) => { + const { batchFiles } = JSON.parse(text ?? "{}") as { batchFiles: Array<{ key: string }> } + return { + response: "objectResponse", + text: JSON.stringify({ [batchFiles[0].key]: true, [batchFiles[1].key]: false }), + images: undefined, + } + }) + const fileResults = [ + { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, + { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, + ] + const updates = new Map>() + + await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { + updates.set(filePath, update) + }) + + expect(updates.get("one.ts")).toMatchObject({ status: "approved" }) + expect(updates.get("two.ts")).toMatchObject({ status: "denied" }) + expect(task.didRejectTool).toBe(true) + }) }) describe("output structure", () => { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 381cf0c1e0..8d1273b499 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -831,7 +831,7 @@ }, "core/task/__tests__/ask-queued-message-drain.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 18 + "count": 14 } }, "core/task/__tests__/flushPendingToolResultsToHistory.spec.ts": { From b9fb2b1398e6e1574bf6a80045712e0d11091dd8 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 01:02:40 +0000 Subject: [PATCH 05/16] test(api): cover image-only streaming input --- src/extension/__tests__/api-send-message.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/extension/__tests__/api-send-message.spec.ts b/src/extension/__tests__/api-send-message.spec.ts index d198919b19..7d31a5a5aa 100644 --- a/src/extension/__tests__/api-send-message.spec.ts +++ b/src/extension/__tests__/api-send-message.spec.ts @@ -72,6 +72,20 @@ describe("API - SendMessage Command", () => { expect(mockPostMessageToWebview).not.toHaveBeenCalled() }) + it("should enqueue image-only input when the current task is streaming", async () => { + const addMessage = vi.fn() + const images = ["data:image/png;base64,image1data"] + mockProvider.getCurrentTask = vi.fn().mockReturnValue({ + isStreaming: true, + messageQueueService: { addMessage }, + }) + + await api.sendMessage(undefined, images) + + expect(addMessage).toHaveBeenCalledWith("", images) + expect(mockPostMessageToWebview).not.toHaveBeenCalled() + }) + it("should retain webview routing when the current task is not streaming", async () => { const addMessage = vi.fn() const messageText = "Answer the current ask" From d511718e169939e2586b255ee7c8075e0284c781 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 01:10:14 +0000 Subject: [PATCH 06/16] test(read-file): distinguish explicit batch denial --- src/core/tools/__tests__/readFileTool.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6adfe6c292..3e665e18e1 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -693,6 +693,7 @@ describe("ReadFileTool", () => { Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) Object.assign(task, createMockTask()) task.ask = vi.fn().mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) + const parseSpy = vi.spyOn(JSON, "parse") const fileResults = [ { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, @@ -700,8 +701,10 @@ describe("ReadFileTool", () => { await readFileTool["requestApproval"](task, fileResults, () => {}) + expect(parseSpy).not.toHaveBeenCalled() expect(task.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) expect(task.didRejectTool).toBe(true) + parseSpy.mockRestore() }) it("applies individual decisions for a batch read", async () => { From 053399d30798a017f73e9e290687bd1f793a7055 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 01:29:24 +0000 Subject: [PATCH 07/16] test(e2e): launch MCP fixture with Electron Node mode --- apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts b/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts index 21e189d7c1..a413e37600 100644 --- a/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts +++ b/apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts @@ -64,9 +64,10 @@ suite("Roo Code use_mcp_tool Tool", function () { { mcpServers: { [FILESYSTEM_SERVER_NAME]: { - command: process.env.npm_node_execpath ?? "node", + command: process.execPath, args: [path.join(__dirname, "fixtures", "filesystem-mcp-server.js"), workspaceDir], env: { + ELECTRON_RUN_AS_NODE: "1", MCP_TEST_READY_FILE: mcpServerReadyPath, }, alwaysAllow: [ From 49ff145e7e4883bdb97ac65917db9cde92f2b1f2 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sat, 5 Sep 2026 01:57:05 +0000 Subject: [PATCH 08/16] test: align focused suites with mutation discovery --- .../__tests__/{readFileTool.spec.ts => ReadFileTool.spec.ts} | 0 src/eslint-suppressions.json | 4 ++-- .../__tests__/{api-send-message.spec.ts => api.spec.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/core/tools/__tests__/{readFileTool.spec.ts => ReadFileTool.spec.ts} (100%) rename src/extension/__tests__/{api-send-message.spec.ts => api.spec.ts} (100%) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/ReadFileTool.spec.ts similarity index 100% rename from src/core/tools/__tests__/readFileTool.spec.ts rename to src/core/tools/__tests__/ReadFileTool.spec.ts diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 8d1273b499..36e4437849 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -974,7 +974,7 @@ "count": 26 } }, - "core/tools/__tests__/readFileTool.spec.ts": { + "core/tools/__tests__/ReadFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 98 } @@ -1139,7 +1139,7 @@ "count": 1 } }, - "extension/__tests__/api-send-message.spec.ts": { + "extension/__tests__/api.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 7 } diff --git a/src/extension/__tests__/api-send-message.spec.ts b/src/extension/__tests__/api.spec.ts similarity index 100% rename from src/extension/__tests__/api-send-message.spec.ts rename to src/extension/__tests__/api.spec.ts From 21b79dbaad64cedddc93ed1f605e025855e01a6c Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 18:47:16 +0000 Subject: [PATCH 09/16] fix: preserve image-only read feedback --- apps/vscode-e2e/src/fixtures/subtasks.ts | 2 +- apps/vscode-e2e/src/suite/subtasks.test.ts | 2 +- src/core/tools/ReadFileTool.ts | 30 ++++++++++++------- src/core/tools/__tests__/ReadFileTool.spec.ts | 30 ++++++++++++++++++- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/apps/vscode-e2e/src/fixtures/subtasks.ts b/apps/vscode-e2e/src/fixtures/subtasks.ts index 7ce8122185..34eeff2a70 100644 --- a/apps/vscode-e2e/src/fixtures/subtasks.ts +++ b/apps/vscode-e2e/src/fixtures/subtasks.ts @@ -191,7 +191,7 @@ export function addSubtaskFixtures(mock: InstanceType) { mock.addFixture({ match: { - userMessage: new RegExp(SUBTASK_QUEUED_INPUT_PARENT_MARKER), + userMessage: SUBTASK_QUEUED_INPUT_PARENT_MARKER, sequenceIndex: 0, }, response: { diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index 1116ebf89c..f4350b90dc 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -270,7 +270,7 @@ suite("Roo Code Subtasks", function () { const says: Record = {} const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => { - if (message.type === "say" && message.partial === false) { + if (message.type === "say" && message.partial !== true) { says[taskId] = says[taskId] || [] says[taskId].push(message) } diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index fa26e32630..ecf1e08fc5 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -447,16 +447,17 @@ export class ReadFileTool extends BaseTool<"read_file"> { const completeMessage = JSON.stringify({ tool: "readFile", batchFiles } satisfies ClineSayTool) const { response, text, images } = await task.ask("tool", completeMessage, false) + const hasFeedback = Boolean(text || images?.length) if (response === "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) + if (hasFeedback) await task.say("user_feedback", text, images) filesToApprove.forEach((fr) => { updateFileResult(fr.path, { status: "approved", feedbackText: text, feedbackImages: images }) }) } else if (response === "noButtonClicked" || response === "messageResponse") { // A queued conversational message resolves the ask as messageResponse; // it is feedback, not the JSON payload used by per-file permissions. - if (text) await task.say("user_feedback", text, images) + if (hasFeedback) await task.say("user_feedback", text, images) task.didRejectTool = true filesToApprove.forEach((fr) => { updateFileResult(fr.path, { @@ -518,9 +519,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { } satisfies ClineSayTool) const { response, text, images } = await task.ask("tool", completeMessage, false) + const hasFeedback = Boolean(text || images?.length) if (response !== "yesButtonClicked") { - if (text) await task.say("user_feedback", text, images) + if (hasFeedback) await task.say("user_feedback", text, images) task.didRejectTool = true updateFileResult(relPath, { status: "denied", @@ -529,7 +531,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { feedbackImages: images, }) } else { - if (text) await task.say("user_feedback", text, images) + if (hasFeedback) await task.say("user_feedback", text, images) updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images }) } } @@ -582,17 +584,25 @@ export class ReadFileTool extends BaseTool<"read_file"> { let statusMessage = "" let feedbackImages: string[] = [] - const deniedWithFeedback = fileResults.find((r) => r.status === "denied" && r.feedbackText) + const deniedWithFeedback = fileResults.find( + (r) => r.status === "denied" && (r.feedbackText || r.feedbackImages?.length), + ) - if (deniedWithFeedback?.feedbackText) { - statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) + if (deniedWithFeedback) { + statusMessage = deniedWithFeedback.feedbackText + ? formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) + : formatResponse.toolDenied() feedbackImages = deniedWithFeedback.feedbackImages || [] } else if (task.didRejectTool) { statusMessage = formatResponse.toolDenied() } else { - const approvedWithFeedback = fileResults.find((r) => r.status === "approved" && r.feedbackText) - if (approvedWithFeedback?.feedbackText) { - statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) + const approvedWithFeedback = fileResults.find( + (r) => r.status === "approved" && (r.feedbackText || r.feedbackImages?.length), + ) + if (approvedWithFeedback) { + statusMessage = approvedWithFeedback.feedbackText + ? formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) + : "" feedbackImages = approvedWithFeedback.feedbackImages || [] } } diff --git a/src/core/tools/__tests__/ReadFileTool.spec.ts b/src/core/tools/__tests__/ReadFileTool.spec.ts index 3e665e18e1..efddf1ca46 100644 --- a/src/core/tools/__tests__/ReadFileTool.spec.ts +++ b/src/core/tools/__tests__/ReadFileTool.spec.ts @@ -698,15 +698,43 @@ describe("ReadFileTool", () => { { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, ] + const updates = new Map>() - await readFileTool["requestApproval"](task, fileResults, () => {}) + await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { + updates.set(filePath, update) + }) expect(parseSpy).not.toHaveBeenCalled() expect(task.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) expect(task.didRejectTool).toBe(true) + expect(updates.get("one.ts")).toMatchObject({ status: "denied" }) + expect(updates.get("two.ts")).toMatchObject({ status: "denied" }) parseSpy.mockRestore() }) + it("preserves image-only feedback when denying batch reads", async () => { + const task = Object.create(Task.prototype) as Task + Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) + Object.assign(task, createMockTask({ supportsImages: true })) + const queuedImages = ["data:image/png;base64,queued"] + task.ask = vi.fn().mockResolvedValue({ response: "messageResponse", text: undefined, images: queuedImages }) + const callbacks = createMockCallbacks() + const fileResults = [ + { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, + { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, + ] + + await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { + Object.assign(fileResults.find(({ path }) => path === filePath)!, update) + }) + readFileTool["buildAndPushResult"](task, fileResults, callbacks.pushToolResult) + + expect(task.say).toHaveBeenCalledWith("user_feedback", undefined, queuedImages) + expect(callbacks.pushToolResult).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ type: "image" })]), + ) + }) + it("applies individual decisions for a batch read", async () => { const task = Object.create(Task.prototype) as Task Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) From 5d6a4f42d435f46cf2856629fd61704945ef77db Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 18:51:30 +0000 Subject: [PATCH 10/16] refactor: narrow image feedback handling --- src/core/tools/ReadFileTool.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index ecf1e08fc5..d033acd499 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -447,17 +447,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { const completeMessage = JSON.stringify({ tool: "readFile", batchFiles } satisfies ClineSayTool) const { response, text, images } = await task.ask("tool", completeMessage, false) - const hasFeedback = Boolean(text || images?.length) if (response === "yesButtonClicked") { - if (hasFeedback) await task.say("user_feedback", text, images) + if (text) await task.say("user_feedback", text, images) filesToApprove.forEach((fr) => { updateFileResult(fr.path, { status: "approved", feedbackText: text, feedbackImages: images }) }) } else if (response === "noButtonClicked" || response === "messageResponse") { // A queued conversational message resolves the ask as messageResponse; // it is feedback, not the JSON payload used by per-file permissions. - if (hasFeedback) await task.say("user_feedback", text, images) + if (text || images?.length) await task.say("user_feedback", text, images) task.didRejectTool = true filesToApprove.forEach((fr) => { updateFileResult(fr.path, { @@ -519,10 +518,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { } satisfies ClineSayTool) const { response, text, images } = await task.ask("tool", completeMessage, false) - const hasFeedback = Boolean(text || images?.length) if (response !== "yesButtonClicked") { - if (hasFeedback) await task.say("user_feedback", text, images) + if (text) await task.say("user_feedback", text, images) task.didRejectTool = true updateFileResult(relPath, { status: "denied", @@ -531,7 +529,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { feedbackImages: images, }) } else { - if (hasFeedback) await task.say("user_feedback", text, images) + if (text) await task.say("user_feedback", text, images) updateFileResult(relPath, { status: "approved", feedbackText: text, feedbackImages: images }) } } @@ -596,13 +594,9 @@ export class ReadFileTool extends BaseTool<"read_file"> { } else if (task.didRejectTool) { statusMessage = formatResponse.toolDenied() } else { - const approvedWithFeedback = fileResults.find( - (r) => r.status === "approved" && (r.feedbackText || r.feedbackImages?.length), - ) - if (approvedWithFeedback) { - statusMessage = approvedWithFeedback.feedbackText - ? formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) - : "" + const approvedWithFeedback = fileResults.find((r) => r.status === "approved" && r.feedbackText) + if (approvedWithFeedback?.feedbackText) { + statusMessage = formatResponse.toolApprovedWithFeedback(approvedWithFeedback.feedbackText) feedbackImages = approvedWithFeedback.feedbackImages || [] } } From a194497e03758f40e5a0af1063bdb6e66eff8557 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 18:54:44 +0000 Subject: [PATCH 11/16] test: strengthen batch feedback mutation coverage --- src/core/tools/__tests__/ReadFileTool.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/ReadFileTool.spec.ts b/src/core/tools/__tests__/ReadFileTool.spec.ts index efddf1ca46..cde9227f42 100644 --- a/src/core/tools/__tests__/ReadFileTool.spec.ts +++ b/src/core/tools/__tests__/ReadFileTool.spec.ts @@ -705,7 +705,7 @@ describe("ReadFileTool", () => { }) expect(parseSpy).not.toHaveBeenCalled() - expect(task.say).not.toHaveBeenCalledWith("user_feedback", expect.anything(), expect.anything()) + expect(task.say).not.toHaveBeenCalled() expect(task.didRejectTool).toBe(true) expect(updates.get("one.ts")).toMatchObject({ status: "denied" }) expect(updates.get("two.ts")).toMatchObject({ status: "denied" }) @@ -727,6 +727,7 @@ describe("ReadFileTool", () => { await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { Object.assign(fileResults.find(({ path }) => path === filePath)!, update) }) + Object.assign(fileResults[0], { status: "approved", feedbackImages: undefined }) readFileTool["buildAndPushResult"](task, fileResults, callbacks.pushToolResult) expect(task.say).toHaveBeenCalledWith("user_feedback", undefined, queuedImages) From 003f164712b4b1dd0604a6a0892a6506ff937127 Mon Sep 17 00:00:00 2001 From: Roomote Date: Mon, 7 Sep 2026 18:58:11 +0000 Subject: [PATCH 12/16] test: distinguish image feedback results --- src/core/tools/__tests__/ReadFileTool.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/tools/__tests__/ReadFileTool.spec.ts b/src/core/tools/__tests__/ReadFileTool.spec.ts index cde9227f42..4c4ef50547 100644 --- a/src/core/tools/__tests__/ReadFileTool.spec.ts +++ b/src/core/tools/__tests__/ReadFileTool.spec.ts @@ -727,7 +727,7 @@ describe("ReadFileTool", () => { await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { Object.assign(fileResults.find(({ path }) => path === filePath)!, update) }) - Object.assign(fileResults[0], { status: "approved", feedbackImages: undefined }) + Object.assign(fileResults[0], { feedbackImages: undefined }) readFileTool["buildAndPushResult"](task, fileResults, callbacks.pushToolResult) expect(task.say).toHaveBeenCalledWith("user_feedback", undefined, queuedImages) From 468843fbc8453068e6254f46b6422818775134b5 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 03:19:58 +0000 Subject: [PATCH 13/16] test(e2e): wait for queued-input child request --- apps/vscode-e2e/src/suite/subtasks.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/vscode-e2e/src/suite/subtasks.test.ts b/apps/vscode-e2e/src/suite/subtasks.test.ts index f4350b90dc..0da28c1aca 100644 --- a/apps/vscode-e2e/src/suite/subtasks.test.ts +++ b/apps/vscode-e2e/src/suite/subtasks.test.ts @@ -30,6 +30,7 @@ import { SUBTASK_QUEUED_INPUT_CHILD_MARKER, SUBTASK_QUEUED_INPUT_CHILD_RESULT, SUBTASK_QUEUED_INPUT_MESSAGE, + SUBTASK_QUEUED_INPUT_PARENT_MARKER, SUBTASK_QUEUED_INPUT_PARENT_PROMPT, SUBTASK_QUEUED_INPUT_PARENT_RESULT, SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT, @@ -300,7 +301,7 @@ suite("Roo Code Subtasks", function () { return false }) - await waitForAimockRequestContaining(SUBTASK_QUEUED_INPUT_CHILD_MARKER) + await waitForAimockRequestContaining(SUBTASK_QUEUED_INPUT_CHILD_MARKER, SUBTASK_QUEUED_INPUT_PARENT_MARKER) const completedParentTaskId = await waitUntilCompleted({ api, From ab42ba38631645f60f4a07cdb72e563c95846f44 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 03:57:21 +0000 Subject: [PATCH 14/16] fix: validate streaming message images --- .../__tests__/resolveImageMentions.spec.ts | 13 +- src/core/mentions/resolveImageMentions.ts | 36 +++++- src/core/tools/ReadFileTool.ts | 20 ++-- ...dFileTool.spec.ts => readFileTool.spec.ts} | 113 ------------------ src/eslint-suppressions.json | 7 +- src/extension/__tests__/api.spec.ts | 43 +++++-- src/extension/api.ts | 5 +- 7 files changed, 95 insertions(+), 142 deletions(-) rename src/core/tools/__tests__/{ReadFileTool.spec.ts => readFileTool.spec.ts} (90%) diff --git a/src/core/mentions/__tests__/resolveImageMentions.spec.ts b/src/core/mentions/__tests__/resolveImageMentions.spec.ts index 9169f40ef7..f20218f4c2 100644 --- a/src/core/mentions/__tests__/resolveImageMentions.spec.ts +++ b/src/core/mentions/__tests__/resolveImageMentions.spec.ts @@ -1,6 +1,6 @@ import * as path from "path" -import { resolveImageMentions } from "../resolveImageMentions" +import { normalizeSuppliedImages, resolveImageMentions } from "../resolveImageMentions" vi.mock("../../tools/helpers/imageHelpers", () => ({ isSupportedImageFormat: vi.fn((ext: string) => @@ -193,3 +193,14 @@ describe("resolveImageMentions", () => { expect(mockValidateImage).toHaveBeenCalledWith(expect.any(String), true, 10, 50, 0) }) }) + +describe("normalizeSuppliedImages", () => { + it("should enforce per-image and total decoded size limits", () => { + const image = `data:image/png;base64,${Buffer.from("four bytes").toString("base64")}` + const secondImage = `data:image/png;base64,${Buffer.from("nine bytes").toString("base64")}` + const sizeInMB = Buffer.byteLength("four bytes") / (1024 * 1024) + + expect(normalizeSuppliedImages([image], { maxImageFileSize: sizeInMB / 2 })).toEqual([]) + expect(normalizeSuppliedImages([image, secondImage], { maxTotalImageSize: sizeInMB * 1.5 })).toEqual([image]) + }) +}) diff --git a/src/core/mentions/resolveImageMentions.ts b/src/core/mentions/resolveImageMentions.ts index 0a0344348f..383c264b64 100644 --- a/src/core/mentions/resolveImageMentions.ts +++ b/src/core/mentions/resolveImageMentions.ts @@ -12,6 +12,11 @@ import { const MAX_IMAGES_PER_MESSAGE = 20 +interface NormalizeSuppliedImagesOptions { + maxImageFileSize?: number + maxTotalImageSize?: number +} + export interface ResolveImageMentionsOptions { text: string images?: string[] @@ -46,6 +51,35 @@ function dedupePreserveOrder(values: string[]): string[] { return result } +export function normalizeSuppliedImages( + images?: string[], + { + maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, + maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, + }: NormalizeSuppliedImagesOptions = {}, +): string[] { + const normalized: string[] = [] + let totalSize = 0 + + for (const image of images ?? []) { + if (normalized.length >= MAX_IMAGES_PER_MESSAGE) break + + const match = image.match(/^data:image\/([a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/]+={0,2})$/) + if (!match || match[2].length % 4 !== 0) continue + + const extension = match[1] === "svg+xml" ? ".svg" : match[1] === "x-icon" ? ".ico" : `.${match[1]}` + if (!isSupportedImageFormat(extension)) continue + + const sizeInMB = Buffer.byteLength(match[2], "base64") / (1024 * 1024) + if (sizeInMB > maxImageFileSize || totalSize + sizeInMB > maxTotalImageSize) continue + + totalSize += sizeInMB + normalized.push(image) + } + + return dedupePreserveOrder(normalized) +} + /** * Resolves local image file mentions like `@/path/to/image.png` found in `text` into `data:image/...;base64,...` * and appends them to the outgoing `images` array. @@ -66,7 +100,7 @@ export async function resolveImageMentions({ maxImageFileSize = DEFAULT_MAX_IMAGE_FILE_SIZE_MB, maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, }: ResolveImageMentionsOptions): Promise { - const existingImages = Array.isArray(images) ? images : [] + const existingImages = normalizeSuppliedImages(images, { maxImageFileSize, maxTotalImageSize }) if (existingImages.length >= MAX_IMAGES_PER_MESSAGE) { return { text, images: existingImages.slice(0, MAX_IMAGES_PER_MESSAGE) } } diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index d033acd499..2107cfe21b 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -292,7 +292,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { output = `IMPORTANT: File content truncated. Status: Showing lines ${start}-${end} of ${result.totalLines} total lines. To read more: Use the read_file tool with offset=${nextOffset} and limit=${effectiveLimit}. - + ${result.content}` } else if (result.includedRanges.length > 0) { const rangeStr = result.includedRanges.map(([s, e]) => `${s}-${e}`).join(", ") @@ -320,7 +320,7 @@ export class ReadFileTool extends BaseTool<"read_file"> { output = `IMPORTANT: File content truncated. Status: Showing lines ${startLine}-${endLine} of ${result.totalLines} total lines. To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. - + ${result.content}` } else if (result.returnedLines === 0) { output = "Note: File is empty" @@ -453,10 +453,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { filesToApprove.forEach((fr) => { updateFileResult(fr.path, { status: "approved", feedbackText: text, feedbackImages: images }) }) - } else if (response === "noButtonClicked" || response === "messageResponse") { - // A queued conversational message resolves the ask as messageResponse; - // it is feedback, not the JSON payload used by per-file permissions. - if (text || images?.length) await task.say("user_feedback", text, images) + } else if (response === "noButtonClicked") { + if (text) await task.say("user_feedback", text, images) task.didRejectTool = true filesToApprove.forEach((fr) => { updateFileResult(fr.path, { @@ -582,14 +580,10 @@ export class ReadFileTool extends BaseTool<"read_file"> { let statusMessage = "" let feedbackImages: string[] = [] - const deniedWithFeedback = fileResults.find( - (r) => r.status === "denied" && (r.feedbackText || r.feedbackImages?.length), - ) + const deniedWithFeedback = fileResults.find((r) => r.status === "denied" && r.feedbackText) - if (deniedWithFeedback) { - statusMessage = deniedWithFeedback.feedbackText - ? formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) - : formatResponse.toolDenied() + if (deniedWithFeedback?.feedbackText) { + statusMessage = formatResponse.toolDeniedWithFeedback(deniedWithFeedback.feedbackText) feedbackImages = deniedWithFeedback.feedbackImages || [] } else if (task.didRejectTool) { statusMessage = formatResponse.toolDenied() diff --git a/src/core/tools/__tests__/ReadFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts similarity index 90% rename from src/core/tools/__tests__/ReadFileTool.spec.ts rename to src/core/tools/__tests__/readFileTool.spec.ts index 4c4ef50547..6c9e177d38 100644 --- a/src/core/tools/__tests__/ReadFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -17,7 +17,6 @@ import path from "path" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" -import { Task } from "../../task/Task" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, @@ -650,118 +649,6 @@ describe("ReadFileTool", () => { expect(mockTask.say).toHaveBeenCalledWith("user_feedback", "This file contains secrets", undefined) expect(formatResponse.toolDeniedWithFeedback).toHaveBeenCalledWith("This file contains secrets") }) - - it("denies batch reads and reports queued message feedback without parsing it as permissions", async () => { - const task = Object.create(Task.prototype) as Task - Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) - Object.assign(task, createMockTask()) - const queuedImages = ["data:image/png;base64,queued"] - task.ask = vi.fn().mockResolvedValue({ - response: "messageResponse", - text: "Read a different file instead", - images: queuedImages, - }) - const fileResults = [ - { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, - { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, - ] - const updates = new Map>() - const parseSpy = vi.spyOn(JSON, "parse") - - await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { - updates.set(filePath, update) - }) - - expect(parseSpy).not.toHaveBeenCalled() - expect(task.say).toHaveBeenCalledWith("user_feedback", "Read a different file instead", queuedImages) - expect(task.didRejectTool).toBe(true) - expect(updates.get("one.ts")).toMatchObject({ - status: "denied", - feedbackText: "Read a different file instead", - feedbackImages: queuedImages, - }) - expect(updates.get("two.ts")).toMatchObject({ - status: "denied", - feedbackText: "Read a different file instead", - feedbackImages: queuedImages, - }) - parseSpy.mockRestore() - }) - - it("denies batch reads without feedback text", async () => { - const task = Object.create(Task.prototype) as Task - Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) - Object.assign(task, createMockTask()) - task.ask = vi.fn().mockResolvedValue({ response: "noButtonClicked", text: undefined, images: undefined }) - const parseSpy = vi.spyOn(JSON, "parse") - const fileResults = [ - { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, - { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, - ] - const updates = new Map>() - - await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { - updates.set(filePath, update) - }) - - expect(parseSpy).not.toHaveBeenCalled() - expect(task.say).not.toHaveBeenCalled() - expect(task.didRejectTool).toBe(true) - expect(updates.get("one.ts")).toMatchObject({ status: "denied" }) - expect(updates.get("two.ts")).toMatchObject({ status: "denied" }) - parseSpy.mockRestore() - }) - - it("preserves image-only feedback when denying batch reads", async () => { - const task = Object.create(Task.prototype) as Task - Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) - Object.assign(task, createMockTask({ supportsImages: true })) - const queuedImages = ["data:image/png;base64,queued"] - task.ask = vi.fn().mockResolvedValue({ response: "messageResponse", text: undefined, images: queuedImages }) - const callbacks = createMockCallbacks() - const fileResults = [ - { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, - { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, - ] - - await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { - Object.assign(fileResults.find(({ path }) => path === filePath)!, update) - }) - Object.assign(fileResults[0], { feedbackImages: undefined }) - readFileTool["buildAndPushResult"](task, fileResults, callbacks.pushToolResult) - - expect(task.say).toHaveBeenCalledWith("user_feedback", undefined, queuedImages) - expect(callbacks.pushToolResult).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ type: "image" })]), - ) - }) - - it("applies individual decisions for a batch read", async () => { - const task = Object.create(Task.prototype) as Task - Object.defineProperty(task, "cwd", { value: "/test/workspace", writable: true }) - Object.assign(task, createMockTask()) - task.ask = vi.fn().mockImplementation(async (_type, text) => { - const { batchFiles } = JSON.parse(text ?? "{}") as { batchFiles: Array<{ key: string }> } - return { - response: "objectResponse", - text: JSON.stringify({ [batchFiles[0].key]: true, [batchFiles[1].key]: false }), - images: undefined, - } - }) - const fileResults = [ - { path: "one.ts", status: "pending" as const, entry: { path: "one.ts", mode: "slice" as const } }, - { path: "two.ts", status: "pending" as const, entry: { path: "two.ts", mode: "slice" as const } }, - ] - const updates = new Map>() - - await readFileTool["requestApproval"](task, fileResults, (filePath, update) => { - updates.set(filePath, update) - }) - - expect(updates.get("one.ts")).toMatchObject({ status: "approved" }) - expect(updates.get("two.ts")).toMatchObject({ status: "denied" }) - expect(task.didRejectTool).toBe(true) - }) }) describe("output structure", () => { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 36e4437849..d8d45ed1f4 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -974,7 +974,7 @@ "count": 26 } }, - "core/tools/__tests__/ReadFileTool.spec.ts": { + "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 98 } @@ -1139,11 +1139,6 @@ "count": 1 } }, - "extension/__tests__/api.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 7 - } - }, "extension/api.ts": { "@typescript-eslint/no-explicit-any": { "count": 9 diff --git a/src/extension/__tests__/api.spec.ts b/src/extension/__tests__/api.spec.ts index 7d31a5a5aa..5707c0c84c 100644 --- a/src/extension/__tests__/api.spec.ts +++ b/src/extension/__tests__/api.spec.ts @@ -12,8 +12,8 @@ describe("API - SendMessage Command", () => { let api: API let mockOutputChannel: vscode.OutputChannel let mockProvider: ClineProvider - let mockPostMessageToWebview: ReturnType any>> - let mockLog: ReturnType void>> + let mockPostMessageToWebview: ReturnType> + let mockLog: ReturnType void>> beforeEach(() => { // Setup mocks @@ -21,10 +21,11 @@ describe("API - SendMessage Command", () => { appendLine: vi.fn(), } as unknown as vscode.OutputChannel - mockPostMessageToWebview = vi.fn<(...args: any[]) => any>().mockResolvedValue(undefined) + mockPostMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider = { context: {} as vscode.ExtensionContext, + contextProxy: { getValues: vi.fn().mockReturnValue({}) }, postMessageToWebview: mockPostMessageToWebview, on: vi.fn(), getCurrentTaskStack: vi.fn().mockReturnValue([]), @@ -32,12 +33,12 @@ describe("API - SendMessage Command", () => { viewLaunched: true, } as unknown as ClineProvider - mockLog = vi.fn<(...args: any[]) => void>() + mockLog = vi.fn<(message: string) => void>() // Create API instance with logging enabled for testing api = new API(mockOutputChannel, mockProvider, undefined, true) // Override the log method to use our mock - ;(api as any).log = mockLog + Object.defineProperty(api, "log", { value: mockLog }) }) it("should handle SendMessage command with text only", async () => { @@ -59,7 +60,7 @@ describe("API - SendMessage Command", () => { it("should enqueue directly when the current task is streaming", async () => { const addMessage = vi.fn() const messageText = "Use this before completing" - const images = ["data:image/png;base64,image1data"] + const images = ["data:image/png;base64,aW1hZ2Ux"] const currentTask = { isStreaming: true, messageQueueService: { addMessage }, @@ -74,7 +75,7 @@ describe("API - SendMessage Command", () => { it("should enqueue image-only input when the current task is streaming", async () => { const addMessage = vi.fn() - const images = ["data:image/png;base64,image1data"] + const images = ["data:image/png;base64,aW1hZ2Ux"] mockProvider.getCurrentTask = vi.fn().mockReturnValue({ isStreaming: true, messageQueueService: { addMessage }, @@ -86,6 +87,34 @@ describe("API - SendMessage Command", () => { expect(mockPostMessageToWebview).not.toHaveBeenCalled() }) + it("should cap streaming input at 20 images before enqueueing", async () => { + const addMessage = vi.fn() + const images = Array.from( + { length: 21 }, + (_, index) => `data:image/png;base64,${Buffer.from(`image-${index}`).toString("base64")}`, + ) + mockProvider.getCurrentTask = vi.fn().mockReturnValue({ + isStreaming: true, + messageQueueService: { addMessage }, + }) + + await api.sendMessage("Review these", images) + + expect(addMessage).toHaveBeenCalledWith("Review these", images.slice(0, 20)) + }) + + it("should discard malformed streaming image data before enqueueing", async () => { + const addMessage = vi.fn() + mockProvider.getCurrentTask = vi.fn().mockReturnValue({ + isStreaming: true, + messageQueueService: { addMessage }, + }) + + await api.sendMessage("Continue safely", ["not-an-image", "data:image/png;base64,%%%"]) + + expect(addMessage).toHaveBeenCalledWith("Continue safely", []) + }) + it("should retain webview routing when the current task is not streaming", async () => { const addMessage = vi.fn() const messageText = "Answer the current ask" diff --git a/src/extension/api.ts b/src/extension/api.ts index 4ba9a6192c..89fbff6aa4 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -26,6 +26,7 @@ import { IpcServer } from "@roo-code/ipc" import { Package } from "../shared/package" import type { Mode } from "../shared/modes" import { ClineProvider } from "../core/webview/ClineProvider" +import { normalizeSuppliedImages } from "../core/mentions/resolveImageMentions" import { Terminal } from "../integrations/terminal/Terminal" import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry" import { openClineInNewTab } from "../activate/registerCommands" @@ -277,7 +278,9 @@ export class API extends EventEmitter implements RooCodeAPI { // only relay this message back as queueMessage asynchronously, so enqueue // it in the extension host instead of racing task completion. if (currentTask?.isStreaming) { - currentTask.messageQueueService.addMessage(text ?? "", images) + const { maxImageFileSize, maxTotalImageSize } = this.sidebarProvider.contextProxy.getValues() + const normalizedImages = normalizeSuppliedImages(images, { maxImageFileSize, maxTotalImageSize }) + currentTask.messageQueueService.addMessage(text ?? "", normalizedImages) return } From f6205eaab42ebd2e8a72706cbfad17d7a0a3529a Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 04:01:24 +0000 Subject: [PATCH 15/16] test: cover streaming image validation boundaries --- .../__tests__/resolveImageMentions.spec.ts | 36 +++++++++++++++++++ src/extension/__tests__/api.spec.ts | 13 +++++++ 2 files changed, 49 insertions(+) diff --git a/src/core/mentions/__tests__/resolveImageMentions.spec.ts b/src/core/mentions/__tests__/resolveImageMentions.spec.ts index f20218f4c2..4a3e2c204f 100644 --- a/src/core/mentions/__tests__/resolveImageMentions.spec.ts +++ b/src/core/mentions/__tests__/resolveImageMentions.spec.ts @@ -195,12 +195,48 @@ describe("resolveImageMentions", () => { }) describe("normalizeSuppliedImages", () => { + it("should accept supported image data URIs and reject malformed or unsupported values", () => { + const payload = Buffer.from("image").toString("base64") + + expect(normalizeSuppliedImages()).toEqual([]) + expect( + normalizeSuppliedImages([ + `data:image/svg+xml;base64,${payload}`, + `data:image/x-icon;base64,${payload}`, + `data:image/png;base64,${payload}`, + `prefix-data:image/png;base64,${payload}`, + `data:image/png;base64,${payload}-suffix`, + "data:image/png;base64,YQ=", + `data:image/unsupported;base64,${payload}`, + ]), + ).toEqual([ + `data:image/svg+xml;base64,${payload}`, + `data:image/x-icon;base64,${payload}`, + `data:image/png;base64,${payload}`, + ]) + }) + it("should enforce per-image and total decoded size limits", () => { const image = `data:image/png;base64,${Buffer.from("four bytes").toString("base64")}` const secondImage = `data:image/png;base64,${Buffer.from("nine bytes").toString("base64")}` const sizeInMB = Buffer.byteLength("four bytes") / (1024 * 1024) expect(normalizeSuppliedImages([image], { maxImageFileSize: sizeInMB / 2 })).toEqual([]) + expect(normalizeSuppliedImages([image], { maxImageFileSize: sizeInMB })).toEqual([image]) expect(normalizeSuppliedImages([image, secondImage], { maxTotalImageSize: sizeInMB * 1.5 })).toEqual([image]) + expect(normalizeSuppliedImages([image], { maxTotalImageSize: sizeInMB })).toEqual([image]) + }) + + it("should apply supplied-image limits through resolveImageMentions", async () => { + const image = `data:image/png;base64,${Buffer.from("image").toString("base64")}` + + const result = await resolveImageMentions({ + text: "No mentions", + images: [image], + cwd: "/workspace", + maxImageFileSize: 0, + }) + + expect(result.images).toEqual([]) }) }) diff --git a/src/extension/__tests__/api.spec.ts b/src/extension/__tests__/api.spec.ts index 5707c0c84c..6e8427e37e 100644 --- a/src/extension/__tests__/api.spec.ts +++ b/src/extension/__tests__/api.spec.ts @@ -115,6 +115,19 @@ describe("API - SendMessage Command", () => { expect(addMessage).toHaveBeenCalledWith("Continue safely", []) }) + it("should apply configured image size limits before streaming enqueue", async () => { + const addMessage = vi.fn() + mockProvider.contextProxy.getValues = vi.fn().mockReturnValue({ maxImageFileSize: 0 }) + mockProvider.getCurrentTask = vi.fn().mockReturnValue({ + isStreaming: true, + messageQueueService: { addMessage }, + }) + + await api.sendMessage("Continue safely", ["data:image/png;base64,aW1hZ2U="]) + + expect(addMessage).toHaveBeenCalledWith("Continue safely", []) + }) + it("should retain webview routing when the current task is not streaming", async () => { const addMessage = vi.fn() const messageText = "Answer the current ask" From 64b4d80a228ffa20506373e8f96731d9dea30139 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 04:05:01 +0000 Subject: [PATCH 16/16] refactor: make empty image input explicit --- src/core/mentions/resolveImageMentions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/mentions/resolveImageMentions.ts b/src/core/mentions/resolveImageMentions.ts index 383c264b64..bb657e0d05 100644 --- a/src/core/mentions/resolveImageMentions.ts +++ b/src/core/mentions/resolveImageMentions.ts @@ -58,10 +58,12 @@ export function normalizeSuppliedImages( maxTotalImageSize = DEFAULT_MAX_TOTAL_IMAGE_SIZE_MB, }: NormalizeSuppliedImagesOptions = {}, ): string[] { + if (!images?.length) return [] + const normalized: string[] = [] let totalSize = 0 - for (const image of images ?? []) { + for (const image of images) { if (normalized.length >= MAX_IMAGES_PER_MESSAGE) break const match = image.match(/^data:image\/([a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/]+={0,2})$/)