Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7d63bba
fix(task): preserve queued input as feedback
roomote Aug 21, 2026
e308027
fix(task): safely drain queued messages into pending asks
edelauna Aug 26, 2026
6064479
fix(task): enqueue streaming input before child completion
edelauna Aug 27, 2026
94f53c3
test(task): cover rebased queue branches
roomote Sep 5, 2026
b9fb2b1
test(api): cover image-only streaming input
roomote Sep 5, 2026
d511718
test(read-file): distinguish explicit batch denial
roomote Sep 5, 2026
053399d
test(e2e): launch MCP fixture with Electron Node mode
roomote Sep 5, 2026
49ff145
test: align focused suites with mutation discovery
roomote Sep 5, 2026
21b79db
fix: preserve image-only read feedback
roomote Sep 7, 2026
5d6a4f4
refactor: narrow image feedback handling
roomote Sep 7, 2026
a194497
test: strengthen batch feedback mutation coverage
roomote Sep 7, 2026
003f164
test: distinguish image feedback results
roomote Sep 7, 2026
468843f
test(e2e): wait for queued-input child request
roomote Sep 10, 2026
ab42ba3
fix: validate streaming message images
roomote Sep 10, 2026
f6205ea
test: cover streaming image validation boundaries
roomote Sep 10, 2026
64b4d80
refactor: make empty image input explicit
roomote Sep 10, 2026
f3b8416
fix: deduplicate images before queue limits
roomote Sep 11, 2026
fada7e7
test: cover total image budget forwarding
roomote Sep 11, 2026
9c3bf64
fix: share image budget across inputs
roomote Sep 11, 2026
780e170
fix: deduplicate local images before budget
roomote Sep 12, 2026
4aedd69
test: cover duplicate local image budget
roomote Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions apps/vscode-e2e/src/fixtures/subtasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -179,6 +189,81 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
},
})

mock.addFixture({
match: {
userMessage: 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),
Expand Down
73 changes: 73 additions & 0 deletions apps/vscode-e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ 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_MARKER,
SUBTASK_QUEUED_INPUT_PARENT_PROMPT,
SUBTASK_QUEUED_INPUT_PARENT_RESULT,
SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT,
SUBTASK_XPROFILE_PARENT_PROMPT,
SUBTASK_XPROFILE_PARENT_RESULT,
Expand Down Expand Up @@ -260,6 +266,73 @@ suite("Roo Code Subtasks", function () {
}
})

test("queued input interrupts child completion before the parent resumes", async () => {
const api = globalThis.api
const says: Record<string, ClineMessage[]> = {}

const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
if (message.type === "say" && message.partial !== true) {
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, SUBTASK_QUEUED_INPUT_PARENT_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
Expand Down
3 changes: 2 additions & 1 deletion apps/vscode-e2e/src/suite/tools/use-mcp-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
117 changes: 114 additions & 3 deletions src/core/mentions/__tests__/resolveImageMentions.spec.ts
Original file line number Diff line number Diff line change
@@ -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) =>
Expand All @@ -11,9 +11,12 @@ vi.mock("../../tools/helpers/imageHelpers", () => ({
readImageAsDataUrlWithBuffer: vi.fn(),
validateImageForProcessing: vi.fn(),
ImageMemoryTracker: vi.fn().mockImplementation(function () {
let totalMemoryUsed = 0
return {
getTotalMemoryUsed: vi.fn().mockReturnValue(0),
addMemoryUsage: vi.fn(),
getTotalMemoryUsed: vi.fn(() => totalMemoryUsed),
addMemoryUsage: vi.fn((sizeInMB: number) => {
totalMemoryUsed += sizeInMB
}),
}
}),
DEFAULT_MAX_IMAGE_FILE_SIZE_MB: 5,
Expand Down Expand Up @@ -192,4 +195,112 @@ describe("resolveImageMentions", () => {

expect(mockValidateImage).toHaveBeenCalledWith(expect.any(String), true, 10, 50, 0)
})

it("should count supplied images against the local mention size budget", async () => {
const suppliedBytes = Buffer.from("supplied-image")
const suppliedImage = `data:image/png;base64,${suppliedBytes.toString("base64")}`
mockValidateImage.mockResolvedValue({ isValid: false, reason: "memory_limit" })

const result = await resolveImageMentions({
text: "See @/local.png",
images: [suppliedImage],
cwd: "/workspace",
})

expect(mockValidateImage).toHaveBeenCalledWith(
expect.any(String),
true,
5,
20,
suppliedBytes.byteLength / (1024 * 1024),
)
expect(result.images).toEqual([suppliedImage])
})

it("should not charge duplicate local images against later unique mentions", async () => {
const firstBytes = Buffer.from("first")
const secondBytes = Buffer.from("other")
const thirdBytes = Buffer.from("third")
const first = `data:image/png;base64,${firstBytes.toString("base64")}`
const second = `data:image/png;base64,${secondBytes.toString("base64")}`
const third = `data:image/png;base64,${thirdBytes.toString("base64")}`
const imageSizeInMB = firstBytes.byteLength / (1024 * 1024)
const maxTotalImageSize = imageSizeInMB * 3
mockValidateImage.mockImplementation(async (_path, _supportsImages, _maxFileSize, maxTotal, current) => ({
isValid: current + imageSizeInMB <= maxTotal,
sizeInMB: imageSizeInMB,
}))
mockReadImageAsDataUrl
.mockResolvedValueOnce({ dataUrl: first, buffer: firstBytes })
.mockResolvedValueOnce({ dataUrl: second, buffer: secondBytes })
.mockResolvedValueOnce({ dataUrl: second, buffer: secondBytes })
.mockResolvedValueOnce({ dataUrl: third, buffer: thirdBytes })

const result = await resolveImageMentions({
text: "See @/supplied-duplicate.png, @/local.png, @/local-duplicate.png, and @/unique.png",
images: [first],
cwd: "/workspace",
maxTotalImageSize,
})

expect(result.images).toEqual([first, second, third])
})
})

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([])
})

it("should not let duplicates consume the image count or size budgets", () => {
const first = `data:image/png;base64,${Buffer.from("first").toString("base64")}`
const second = `data:image/png;base64,${Buffer.from("second").toString("base64")}`
const maxTotalImageSize = Buffer.byteLength("firstsecond") / (1024 * 1024)

expect(normalizeSuppliedImages([...Array(20).fill(first), second], { maxTotalImageSize })).toEqual([
first,
second,
])
})
})
Loading
Loading