Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 additions & 0 deletions apps/vscode-e2e/src/fixtures/queued-api-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { LLMock } from "@copilotkit/aimock"
import type { ChatCompletionRequest } from "@copilotkit/aimock"

export const QUEUED_API_INPUT_PROMPT = "QUEUED_API_INPUT_APPROVAL: Run the marker command."
export const QUEUED_API_INPUT_MARKER_FILE = "queued-api-input-marker.txt"
export const QUEUED_API_INPUT_MESSAGE = "Steering note from the API."
export const QUEUED_API_INPUT_RESPONSE_LATENCY_MS = 2_000

const COMMAND_CALL_ID = "call_queued_api_input_command_001"

export function addQueuedApiInputFixtures(mock: InstanceType<typeof LLMock>) {
mock.addFixture({
match: {
predicate: (req: ChatCompletionRequest) => {
const messages = Array.isArray(req?.messages) ? req.messages : []
const lastUser = messages.filter((m) => m?.role === "user").at(-1)
return JSON.stringify(lastUser?.content ?? "").includes(QUEUED_API_INPUT_PROMPT)
},
},
streamingProfile: { ttft: QUEUED_API_INPUT_RESPONSE_LATENCY_MS },
response: {
toolCalls: [
{
name: "execute_command",
arguments: JSON.stringify({ command: `touch ${QUEUED_API_INPUT_MARKER_FILE}` }),
id: COMMAND_CALL_ID,
},
],
},
})

mock.addFixture({
match: {
predicate: (req: ChatCompletionRequest) => {
const messages = Array.isArray(req?.messages) ? req.messages : []
return messages.filter((m) => m?.role === "tool").at(-1)?.tool_call_id === COMMAND_CALL_ID
},
},
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: "Ran the marker command." }),
id: "call_queued_api_input_completion_002",
},
],
},
})
}
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: 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),
Expand Down
2 changes: 2 additions & 0 deletions apps/vscode-e2e/src/runTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { addTerminalProfileResultFixtures } from "./fixtures/terminal-profile"
import { addListFilesResultFixtures } from "./fixtures/list-files"
import { addReadFileResultFixtures } from "./fixtures/read-file"
import { addSearchFilesResultFixtures } from "./fixtures/search-files"
import { addQueuedApiInputFixtures } from "./fixtures/queued-api-input"
import { addSubtaskFixtures } from "./fixtures/subtasks"
import { addUseMcpToolResultFixtures } from "./fixtures/use-mcp-tool"
import { addWriteToFileResultFixtures } from "./fixtures/write-to-file"
Expand Down Expand Up @@ -140,6 +141,7 @@ async function main() {
addReadFileResultFixtures(mock)
addSearchFilesResultFixtures(mock)
addSubtaskFixtures(mock)
addQueuedApiInputFixtures(mock)
addUseMcpToolResultFixtures(mock)
addWriteToFileResultFixtures(mock)
addDeepSeekV4Fixtures(mock)
Expand Down
99 changes: 99 additions & 0 deletions apps/vscode-e2e/src/suite/queued-api-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { providerIdentifiers } from "@roo-code/types"
import * as assert from "assert"
import * as fs from "fs/promises"
import * as path from "path"
import * as vscode from "vscode"

import { RooCodeEventName, type ClineMessage, type QueuedMessage } from "@roo-code/types"

import { setDefaultSuiteTimeout } from "./test-utils"
import { sleep, waitFor } from "./utils"
import {
QUEUED_API_INPUT_MARKER_FILE,
QUEUED_API_INPUT_MESSAGE,
QUEUED_API_INPUT_PROMPT,
} from "../fixtures/queued-api-input"

suite("Roo Code queued API input", function () {
setDefaultSuiteTimeout(this)

let markerPath: string

suiteSetup(async () => {
const aimockUrl = process.env.AIMOCK_URL
await globalThis.api.setConfiguration({
apiProvider: providerIdentifiers.openrouter,
openRouterApiKey: aimockUrl ? "mock-key" : process.env.OPENROUTER_API_KEY!,
openRouterModelId: "anthropic/claude-sonnet-4.5",
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
})
markerPath = path.join(vscode.workspace.workspaceFolders![0]!.uri.fsPath, QUEUED_API_INPUT_MARKER_FILE)
})

teardown(async () => {
await fs.rm(markerPath, { force: true })
while (globalThis.api.getCurrentTaskStack().length > 0) {
await globalThis.api.clearCurrentTask()
}
})

test("queued API input does not approve a protected command ask", async () => {
const api = globalThis.api
await fs.rm(markerPath, { force: true })

let queue: QueuedMessage[] = []
let commandAsk: ClineMessage | undefined
let interactive = false

const onQueue = (_taskId: string, messages: QueuedMessage[]) => {
if (messages.length > 0) queue = messages
}
const onMessage = ({ message }: { message: ClineMessage }) => {
if (message.type === "ask" && message.ask === "command" && message.partial !== true) {
commandAsk = message
}
}
const onInteractive = () => {
interactive = true
}

api.on(RooCodeEventName.QueuedMessagesUpdated, onQueue)
api.on(RooCodeEventName.Message, onMessage)
api.on(RooCodeEventName.TaskInteractive, onInteractive)

try {
const taskId = await api.startNewTask({
configuration: { mode: "code", autoApprovalEnabled: false, enableCheckpoints: false },
text: QUEUED_API_INPUT_PROMPT,
})

// Wait until the first request reaches the mock. The mock then delays its
// response, so the task is still streaming when the API input arrives.
await waitFor(async () => {
const response = await fetch(`${process.env.AIMOCK_URL}/__aimock/journal`)
return JSON.stringify(await response.json()).includes(QUEUED_API_INPUT_PROMPT)
})
await api.sendMessage(QUEUED_API_INPUT_MESSAGE)

await waitFor(() => commandAsk !== undefined)
await waitFor(() => interactive)
await sleep(1_500)

assert.ok(queue.some((m) => m.text === QUEUED_API_INPUT_MESSAGE && m.origin === "api"))
await assert.rejects(fs.access(markerPath), "Queued API input must not run the command")
assert.strictEqual(api.getCurrentTaskStack().at(-1), taskId)

await api.approveCurrentAsk()
await waitFor(() =>
fs.access(markerPath).then(
() => true,
() => false,
),
)
} finally {
api.off(RooCodeEventName.QueuedMessagesUpdated, onQueue)
api.off(RooCodeEventName.Message, onMessage)
api.off(RooCodeEventName.TaskInteractive, onInteractive)
}
})
})
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
5 changes: 4 additions & 1 deletion packages/types/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
*/
abandonSubtask(childTaskId: string): Promise<boolean>
/**
* Sends a message to the current task.
* Sends a message to the current task as conversational input.
* If the task is busy the message is queued and becomes the next user
* turn. Queued input never approves a pending or later tool, command, or
* MCP ask; use approveCurrentAsk() for explicit approval.
* @param message Optional message to send.
* @param images Optional array of image data URIs (e.g., "data:image/webp;base64,...").
*/
Expand Down
6 changes: 6 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,12 @@
id: z.string(),
text: z.string(),
images: z.array(z.string()).optional(),
/**
* Where the message was queued from. Absent means the interactive
* webview, whose queued input may answer approval asks. "api" input is
* conversational steering and never answers an approval ask.
*/
origin: z.enum(["webview", "api"]).optional(),

Check warning on line 380 in packages/types/src/message.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

packages/types/src/message.ts:380: 3 mutation test gaps; example: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
})

export type QueuedMessage = z.infer<typeof queuedMessageSchema>
Loading
Loading