Fix stdin-stream cancel race and add integration test suite (#11817)

Add stdin stream integration tests and fix startup cancel race
This commit is contained in:
Chris Estreich 2026-03-01 22:27:01 -08:00 committed by GitHub
parent a7cebac2d8
commit e6ad7949d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1280 additions and 112 deletions

View file

@ -13,11 +13,11 @@
"lint": "eslint src --ext .ts --max-warnings=0",
"check-types": "tsc --noEmit",
"test": "vitest run",
"test:integration": "tsx scripts/integration/run.ts",
"build": "tsup",
"build:extension": "pnpm --filter roo-cline bundle",
"dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts",
"dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts",
"dev:test-stdin": "tsx scripts/test-stdin-stream.ts",
"clean": "rimraf dist .turbo"
},
"dependencies": {

View file

@ -0,0 +1,104 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 12 && echo "done". After it finishes, reply with exactly "done".'
async function main() {
const startRequestId = `start-a-${Date.now()}`
const cancelRequestId = `cancel-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let startAccepted = false
let startCommandToolUseSeen = false
let sentCancel = false
let cancelDone = false
let sentShutdown = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: LONG_PROMPT,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === startRequestId
) {
startAccepted = true
return
}
if (
event.type === "tool_use" &&
event.subtype === "command" &&
event.done === true &&
event.requestId === startRequestId
) {
startCommandToolUseSeen = true
}
if (startAccepted && startCommandToolUseSeen && !sentCancel) {
context.sendCommand({
command: "cancel",
requestId: cancelRequestId,
})
sentCancel = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "cancel" &&
event.requestId === cancelRequestId
) {
if (event.code === "cancel_requested" || event.code === "no_active_task") {
cancelDone = true
}
return
}
if (cancelDone && !sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
return
}
if (event.type === "control" && event.subtype === "error" && event.requestId === cancelRequestId) {
throw new Error(
`cancel command failed with code=${event.code ?? "unknown"} content="${event.content ?? ""}"`,
)
}
if (event.type === "error") {
throw new Error(`unexpected stream error event: ${event.content ?? "unknown error"}`)
}
},
onTimeoutMessage() {
return `timed out waiting for cancel flow (initSeen=${initSeen}, startAccepted=${startAccepted}, startCommandToolUseSeen=${startCommandToolUseSeen}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`
},
})
if (!startAccepted || !startCommandToolUseSeen || !sentCancel || !cancelDone || !sentShutdown) {
throw new Error(
`cancel flow did not complete expected transitions (startAccepted=${startAccepted}, startCommandToolUseSeen=${startCommandToolUseSeen}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`,
)
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -0,0 +1,83 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 12 && echo "done". After it finishes, reply with exactly "done".'
async function main() {
const startRequestId = `start-${Date.now()}`
const cancelRequestId = `cancel-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let startAccepted = false
let sentCancel = false
let cancelDone = false
let sentShutdown = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: LONG_PROMPT,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === startRequestId &&
!startAccepted
) {
startAccepted = true
context.sendCommand({
command: "cancel",
requestId: cancelRequestId,
})
sentCancel = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "cancel" &&
event.requestId === cancelRequestId
) {
if (event.code === "cancel_requested" || event.code === "no_active_task") {
cancelDone = true
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
}
return
}
if (event.type === "error") {
throw new Error(`unexpected stream error event: ${event.content ?? "unknown error"}`)
}
},
onTimeoutMessage() {
return `timed out waiting for immediate-cancel flow (initSeen=${initSeen}, startAccepted=${startAccepted}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`
},
})
if (!startAccepted || !sentCancel || !cancelDone || !sentShutdown) {
throw new Error(
`immediate-cancel flow did not complete expected transitions (startAccepted=${startAccepted}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`,
)
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -0,0 +1,101 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const FIRST_PROMPT = `What is 1+1? Reply with only "2".`
const FOLLOWUP_PROMPT = `Different question now: what is 3+3? Reply with only "6".`
function parseEventContent(text: string | undefined): string {
return typeof text === "string" ? text : ""
}
function validateFollowupAnswer(text: string): void {
const normalized = text.toLowerCase()
const containsExpected = /\b6\b/.test(normalized) || normalized.includes("six")
const containsOldAnswer = /\b1\+1\b/.test(normalized) || /\b2\b/.test(normalized)
const containsQuestionReference = normalized.includes("3+3")
if (!containsExpected) {
throw new Error(`follow-up result did not answer the follow-up question; result="${text}"`)
}
if (!containsQuestionReference && containsOldAnswer && !containsExpected) {
throw new Error(`follow-up result appears anchored to first question; result="${text}"`)
}
}
async function main() {
const startRequestId = `start-${Date.now()}`
const followupRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let sentFollowup = false
let sentShutdown = false
let firstResult = ""
let followupResult = ""
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: FIRST_PROMPT,
})
return
}
if (event.type === "control" && event.subtype === "error") {
throw new Error(
`received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
}
if (event.type !== "result" || event.done !== true) {
return
}
if (event.requestId === startRequestId) {
firstResult = parseEventContent(event.content)
if (!/\b2\b/.test(firstResult)) {
throw new Error(`first result did not answer first prompt; result="${firstResult}"`)
}
if (!sentFollowup) {
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
})
sentFollowup = true
}
return
}
if (event.requestId !== followupRequestId) {
return
}
followupResult = parseEventContent(event.content)
validateFollowupAnswer(followupResult)
console.log(`[PASS] first result="${firstResult}"`)
console.log(`[PASS] follow-up result="${followupResult}"`)
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
},
onTimeoutMessage() {
return `timed out waiting for completion (initSeen=${initSeen}, sentFollowup=${sentFollowup}, firstResult=${Boolean(firstResult)}, followupResult=${Boolean(followupResult)})`
},
})
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -0,0 +1,161 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const START_PROMPT = 'Answer this question and finish: What is 1+1? Reply with only "2", then complete the task.'
const FOLLOWUP_PROMPT = 'Different question now: what is 3+3? Reply with only "6".'
function looksLikeAttemptCompletionToolUse(event: StreamEvent): boolean {
if (event.type !== "tool_use") {
return false
}
if (event.tool_use?.name === "attempt_completion") {
return true
}
const content = event.content ?? ""
return content.includes('"tool":"attempt_completion"') || content.includes('"name":"attempt_completion"')
}
function validateFollowupAnswer(text: string): void {
const normalized = text.toLowerCase()
const hasSix = /\b6\b/.test(normalized) || normalized.includes("six")
if (!hasSix) {
throw new Error(`follow-up result did not answer follow-up prompt; result="${text}"`)
}
}
async function main() {
const startRequestId = `start-${Date.now()}`
const followupRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let sentFollowup = false
let sentShutdown = false
let sawAttemptCompletion = false
let sawFollowupUserTurn = false
let sawMisroutedToolResult = false
let followupResult = ""
let sawFirstAssistantChunkForStart = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: START_PROMPT,
})
return
}
if (event.type === "control" && event.subtype === "error") {
throw new Error(
`received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
)
}
if (!sawAttemptCompletion && looksLikeAttemptCompletionToolUse(event)) {
sawAttemptCompletion = true
if (!sentFollowup) {
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
})
sentFollowup = true
}
return
}
if (
event.type === "assistant" &&
event.requestId === startRequestId &&
event.done !== true &&
!sawFirstAssistantChunkForStart
) {
sawFirstAssistantChunkForStart = true
if (!sentFollowup) {
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
})
sentFollowup = true
}
return
}
if (
event.type === "tool_result" &&
event.requestId === followupRequestId &&
typeof event.content === "string" &&
event.content.includes("<user_message>")
) {
sawMisroutedToolResult = true
return
}
if (event.type === "user" && event.requestId === followupRequestId) {
sawFollowupUserTurn = typeof event.content === "string" && event.content.includes("3+3")
return
}
if (event.type === "result" && event.done === true && event.requestId === startRequestId && !sentFollowup) {
context.sendCommand({
command: "message",
requestId: followupRequestId,
prompt: FOLLOWUP_PROMPT,
})
sentFollowup = true
return
}
if (event.type !== "result" || event.done !== true || event.requestId !== followupRequestId) {
return
}
followupResult = event.content ?? ""
validateFollowupAnswer(followupResult)
if (sawMisroutedToolResult) {
throw new Error("follow-up message was misrouted into tool_result (<user_message>), old bug reproduced")
}
if (!sawFollowupUserTurn) {
throw new Error("follow-up did not appear as a normal user turn in stream output")
}
console.log(`[PASS] saw attempt_completion tool use: ${sawAttemptCompletion}`)
console.log(`[PASS] saw start assistant chunk before follow-up: ${sawFirstAssistantChunkForStart}`)
console.log(`[PASS] follow-up user turn observed: ${sawFollowupUserTurn}`)
console.log(`[PASS] follow-up result: "${followupResult}"`)
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
},
onTimeoutMessage() {
return [
"timed out waiting for follow-up validation",
`initSeen=${initSeen}`,
`sentFollowup=${sentFollowup}`,
`sawAttemptCompletion=${sawAttemptCompletion}`,
`sawFirstAssistantChunkForStart=${sawFirstAssistantChunkForStart}`,
`sawFollowupUserTurn=${sawFollowupUserTurn}`,
`sawMisroutedToolResult=${sawMisroutedToolResult}`,
`haveFollowupResult=${Boolean(followupResult)}`,
].join(" ")
},
})
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -0,0 +1,51 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
async function main() {
const messageRequestId = `message-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let sawNoActiveTaskError = false
let sentShutdown = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "message",
requestId: messageRequestId,
prompt: "Hello",
})
return
}
if (
event.type === "control" &&
event.subtype === "error" &&
event.requestId === messageRequestId &&
event.code === "no_active_task"
) {
sawNoActiveTaskError = true
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
}
},
onTimeoutMessage() {
return `timed out waiting for no_active_task error (initSeen=${initSeen}, sawNoActiveTaskError=${sawNoActiveTaskError})`
},
})
if (!sawNoActiveTaskError) {
throw new Error("expected no_active_task error was not observed")
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -0,0 +1,184 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 6 && echo "done". After it finishes, reply with exactly "done".'
const MESSAGE_ONE_PROMPT = 'For this follow-up, reply with only "ALPHA".'
const MESSAGE_TWO_PROMPT = 'For this follow-up, reply with only "BETA".'
async function main() {
const startRequestId = `start-${Date.now()}`
const firstMessageRequestId = `message-a-${Date.now()}`
const secondMessageRequestId = `message-b-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let startAccepted = false
let sentQueuedMessages = false
let sentShutdown = false
let firstMessageAccepted = false
let secondMessageAccepted = false
let firstMessageQueued = false
let secondMessageQueued = false
const resultOrder: string[] = []
let queueDequeuedByFirst = false
let queueDrainedBySecond = false
let firstResultSeen = false
let secondResultSeen = false
await runStreamCase({
timeoutMs: 180_000,
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: LONG_PROMPT,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === startRequestId &&
!startAccepted
) {
startAccepted = true
context.sendCommand({
command: "message",
requestId: firstMessageRequestId,
prompt: MESSAGE_ONE_PROMPT,
})
context.sendCommand({
command: "message",
requestId: secondMessageRequestId,
prompt: MESSAGE_TWO_PROMPT,
})
sentQueuedMessages = true
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "message" &&
event.requestId === firstMessageRequestId
) {
firstMessageAccepted = true
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "message" &&
event.requestId === secondMessageRequestId
) {
secondMessageAccepted = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "message" &&
event.requestId === firstMessageRequestId &&
event.code === "queued"
) {
firstMessageQueued = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "message" &&
event.requestId === secondMessageRequestId &&
event.code === "queued"
) {
secondMessageQueued = true
return
}
if (
event.type === "queue" &&
event.subtype === "dequeued" &&
event.requestId === firstMessageRequestId &&
event.queueDepth === 1
) {
queueDequeuedByFirst = true
return
}
if (
event.type === "queue" &&
event.subtype === "drained" &&
event.requestId === secondMessageRequestId &&
event.queueDepth === 0
) {
queueDrainedBySecond = true
return
}
if (event.type === "result" && event.done === true) {
if (event.requestId === firstMessageRequestId) {
firstResultSeen = true
resultOrder.push(firstMessageRequestId)
}
if (event.requestId === secondMessageRequestId) {
secondResultSeen = true
resultOrder.push(secondMessageRequestId)
}
}
if (!firstResultSeen || !secondResultSeen || sentShutdown) {
return
}
const expectedOrder = [firstMessageRequestId, secondMessageRequestId].join(",")
if (resultOrder.join(",") !== expectedOrder) {
throw new Error(
`queued message result order mismatch; expected=${expectedOrder} actual=${resultOrder.join(",")}`,
)
}
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
},
onTimeoutMessage() {
return `timed out waiting for queued message order validation (initSeen=${initSeen}, startAccepted=${startAccepted}, sentQueuedMessages=${sentQueuedMessages}, firstMessageAccepted=${firstMessageAccepted}, secondMessageAccepted=${secondMessageAccepted}, firstMessageQueued=${firstMessageQueued}, secondMessageQueued=${secondMessageQueued}, queueDequeuedByFirst=${queueDequeuedByFirst}, queueDrainedBySecond=${queueDrainedBySecond}, resultOrder=${resultOrder.join(" -> ")}, firstResultSeen=${firstResultSeen}, secondResultSeen=${secondResultSeen})`
},
})
if (
!firstMessageAccepted ||
!secondMessageAccepted ||
!firstMessageQueued ||
!secondMessageQueued ||
!queueDequeuedByFirst ||
!queueDrainedBySecond
) {
throw new Error(
`expected both queued messages to be accepted/queued and queue transitions observed (firstMessageAccepted=${firstMessageAccepted}, secondMessageAccepted=${secondMessageAccepted}, firstMessageQueued=${firstMessageQueued}, secondMessageQueued=${secondMessageQueued}, queueDequeuedByFirst=${queueDequeuedByFirst}, queueDrainedBySecond=${queueDrainedBySecond})`,
)
}
const expectedOrder = [firstMessageRequestId, secondMessageRequestId].join(",")
if (resultOrder.join(",") !== expectedOrder) {
throw new Error(
`queued message result order mismatch; expected=${expectedOrder} actual=${resultOrder.join(",")}`,
)
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -0,0 +1,76 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 20 && echo "done". After it finishes, reply with exactly "done".'
async function main() {
const startRequestId = `start-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let startAccepted = false
let shutdownSent = false
let shutdownAck = false
let shutdownDone = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: startRequestId,
prompt: LONG_PROMPT,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === startRequestId &&
!startAccepted
) {
startAccepted = true
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
shutdownSent = true
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "shutdown" &&
event.requestId === shutdownRequestId
) {
shutdownAck = true
return
}
if (
event.type === "control" &&
event.subtype === "done" &&
event.command === "shutdown" &&
event.requestId === shutdownRequestId
) {
shutdownDone = true
}
},
onTimeoutMessage() {
return `timed out waiting for shutdown flow (initSeen=${initSeen}, startAccepted=${startAccepted}, shutdownSent=${shutdownSent}, shutdownAck=${shutdownAck}, shutdownDone=${shutdownDone})`
},
})
if (!shutdownAck || !shutdownDone) {
throw new Error("shutdown control events were not fully observed")
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -0,0 +1,77 @@
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
const LONG_PROMPT =
'Run exactly this command and do not summarize until it finishes: sleep 8 && echo "done". After it finishes, reply with exactly "done".'
async function main() {
const firstStartRequestId = `start-a-${Date.now()}`
const secondStartRequestId = `start-b-${Date.now()}`
const shutdownRequestId = `shutdown-${Date.now()}`
let initSeen = false
let firstStartAccepted = false
let secondStartSent = false
let sawTaskBusyError = false
let sentShutdown = false
await runStreamCase({
onEvent(event: StreamEvent, context) {
if (event.type === "system" && event.subtype === "init" && !initSeen) {
initSeen = true
context.sendCommand({
command: "start",
requestId: firstStartRequestId,
prompt: LONG_PROMPT,
})
return
}
if (
event.type === "control" &&
event.subtype === "ack" &&
event.command === "start" &&
event.requestId === firstStartRequestId &&
!firstStartAccepted
) {
firstStartAccepted = true
context.sendCommand({
command: "start",
requestId: secondStartRequestId,
prompt: "What is 1+1? Reply with only 2.",
})
secondStartSent = true
return
}
if (
event.type === "control" &&
event.subtype === "error" &&
event.command === "start" &&
event.requestId === secondStartRequestId &&
event.code === "task_busy"
) {
sawTaskBusyError = true
if (!sentShutdown) {
context.sendCommand({
command: "shutdown",
requestId: shutdownRequestId,
})
sentShutdown = true
}
return
}
},
onTimeoutMessage() {
return `timed out waiting for task_busy error (initSeen=${initSeen}, firstStartAccepted=${firstStartAccepted}, secondStartSent=${secondStartSent}, sawTaskBusyError=${sawTaskBusyError})`
},
})
if (!sawTaskBusyError) {
throw new Error("expected task_busy error for second start command was not observed")
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -0,0 +1,151 @@
import path from "path"
import { fileURLToPath } from "url"
import readline from "readline"
import { execa } from "execa"
export type StreamEvent = {
type?: string
subtype?: string
requestId?: string
command?: string
content?: string
code?: string
success?: boolean
done?: boolean
id?: number
queueDepth?: number
queue?: Array<{ id?: string; text?: string; imageCount?: number; timestamp?: number }>
tool_use?: {
name?: string
input?: Record<string, unknown>
}
tool_result?: {
name?: string
output?: string
}
}
export type StreamCommand = {
command: "start" | "message" | "cancel" | "ping" | "shutdown"
requestId: string
prompt?: string
}
export interface StreamCaseContext {
readonly cliRoot: string
readonly timeoutMs: number
nextRequestId(prefix: string): string
sendCommand(command: StreamCommand): void
}
export interface RunStreamCaseOptions {
timeoutMs?: number
onEvent: (event: StreamEvent, context: StreamCaseContext) => void
onTimeoutMessage?: (context: StreamCaseContext) => string
}
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const defaultCliRoot = path.resolve(__dirname, "../../..")
function parseEvent(line: string): StreamEvent | null {
const trimmed = line.trim()
if (!trimmed.startsWith("{")) {
return null
}
try {
return JSON.parse(trimmed) as StreamEvent
} catch {
return null
}
}
export async function runStreamCase(options: RunStreamCaseOptions): Promise<void> {
const cliRoot = process.env.ROO_CLI_ROOT ? path.resolve(process.env.ROO_CLI_ROOT) : defaultCliRoot
const timeoutMs = options.timeoutMs ?? 120_000
const child = execa(
"pnpm",
["dev", "--print", "--stdin-prompt-stream", "--provider", "roo", "--output-format", "stream-json"],
{
cwd: cliRoot,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
reject: false,
forceKillAfterDelay: 2_000,
},
)
child.stderr?.on("data", (chunk) => {
process.stderr.write(chunk)
})
let requestCounter = 0
const context: StreamCaseContext = {
cliRoot,
timeoutMs,
nextRequestId(prefix: string): string {
requestCounter += 1
return `${prefix}-${Date.now()}-${requestCounter}`
},
sendCommand(command: StreamCommand): void {
if (child.stdin?.destroyed) {
return
}
child.stdin.write(`${JSON.stringify(command)}\n`)
},
}
let handlerError: Error | null = null
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
const message = options.onTimeoutMessage?.(context) ?? "timed out waiting for stream scenario completion"
handlerError = new Error(message)
child.kill("SIGTERM")
}, timeoutMs)
const rl = readline.createInterface({
input: child.stdout!,
crlfDelay: Infinity,
})
rl.on("line", (line) => {
process.stdout.write(`${line}\n`)
const event = parseEvent(line)
if (!event) {
return
}
try {
options.onEvent(event, context)
} catch (error) {
handlerError = error instanceof Error ? error : new Error(String(error))
child.kill("SIGTERM")
}
})
const result = await child
clearTimeout(timeout)
rl.close()
if (handlerError) {
throw handlerError
}
if (timedOut) {
throw new Error("stream scenario timed out")
}
if (result.exitCode !== 0) {
throw new Error(`CLI exited with non-zero code: ${result.exitCode}`)
}
}

View file

@ -0,0 +1,111 @@
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { execa } from "execa"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const cliRoot = path.resolve(__dirname, "../..")
const casesDir = path.resolve(__dirname, "cases")
interface RunnerOptions {
listOnly: boolean
match?: string
}
function parseArgs(argv: string[]): RunnerOptions {
let listOnly = false
let match: string | undefined
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === "--list") {
listOnly = true
continue
}
if (arg === "--match") {
match = argv[i + 1]
i += 1
continue
}
}
return { listOnly, match }
}
async function discoverCaseFiles(match?: string): Promise<string[]> {
const entries = await fs.readdir(casesDir, { withFileTypes: true })
const files = entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".ts"))
.map((entry) => path.resolve(casesDir, entry.name))
.sort((a, b) => a.localeCompare(b))
if (!match) {
return files
}
const normalized = match.toLowerCase()
return files.filter((file) => path.basename(file).toLowerCase().includes(normalized))
}
async function runCase(caseFile: string): Promise<void> {
const caseName = path.basename(caseFile, ".ts")
console.log(`\n[RUN] ${caseName}`)
await execa("tsx", [caseFile], {
cwd: cliRoot,
stdio: "inherit",
reject: true,
env: {
...process.env,
ROO_CLI_ROOT: cliRoot,
},
})
console.log(`[PASS] ${caseName}`)
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const caseFiles = await discoverCaseFiles(options.match)
if (caseFiles.length === 0) {
throw new Error(
options.match ? `no integration cases matched --match "${options.match}"` : "no integration cases found",
)
}
if (options.listOnly) {
console.log("Available integration cases:")
for (const file of caseFiles) {
console.log(`- ${path.basename(file, ".ts")}`)
}
return
}
const failures: Array<{ caseName: string; error: string }> = []
for (const caseFile of caseFiles) {
const caseName = path.basename(caseFile, ".ts")
try {
await runCase(caseFile)
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error)
failures.push({ caseName, error: errorText })
console.error(`[FAIL] ${caseName}: ${errorText}`)
}
}
const total = caseFiles.length
const passed = total - failures.length
console.log(`\nSummary: ${passed}/${total} passed`)
if (failures.length > 0) {
process.exitCode = 1
}
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -1,85 +0,0 @@
import path from "path"
import { fileURLToPath } from "url"
import readline from "readline"
import { execa } from "execa"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const cliRoot = path.resolve(__dirname, "..")
async function main() {
const child = execa(
"pnpm",
["dev", "--print", "--stdin-prompt-stream", "--provider", "roo", "--output-format", "stream-json"],
{
cwd: cliRoot,
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
reject: false,
forceKillAfterDelay: 2_000,
},
)
child.stdout?.on("data", (chunk) => process.stdout.write(chunk))
child.stderr?.on("data", (chunk) => process.stderr.write(chunk))
console.log("[wrapper] Type a message and press Enter to send it.")
console.log("[wrapper] Type /exit to close stdin and let the CLI finish.")
let requestCounter = 0
let hasStartedTask = false
const sendCommand = (payload: Record<string, unknown>) => {
if (child.stdin?.destroyed) {
return
}
child.stdin?.write(JSON.stringify(payload) + "\n")
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: true,
})
rl.on("line", (line) => {
if (line.trim() === "/exit") {
console.log("[wrapper] Closing stdin...")
sendCommand({
command: "shutdown",
requestId: `shutdown-${Date.now()}-${++requestCounter}`,
})
child.stdin?.end()
rl.close()
return
}
const command = hasStartedTask ? "message" : "start"
sendCommand({
command,
requestId: `${command}-${Date.now()}-${++requestCounter}`,
prompt: line,
})
hasStartedTask = true
})
const onSignal = (signal: NodeJS.Signals) => {
console.log(`[wrapper] Received ${signal}, forwarding to CLI...`)
rl.close()
child.kill(signal)
}
process.on("SIGINT", () => onSignal("SIGINT"))
process.on("SIGTERM", () => onSignal("SIGTERM"))
const result = await child
rl.close()
console.log(`[wrapper] CLI exited with code ${result.exitCode}`)
process.exit(result.exitCode ?? 1)
}
main().catch((error) => {
console.error("[wrapper] Fatal error:", error)
process.exit(1)
})

View file

@ -294,6 +294,43 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
let hasSeenQueueState = false
let lastQueueDepth = 0
let lastQueueMessageIds: string[] = []
const pendingQueuedMessageRequestIds: string[] = []
const queueMessageRequestIdByMessageId = new Map<string, string>()
const assignRequestIdsToNewQueueMessages = (queueMessageIds: string[]) => {
for (const messageId of queueMessageIds) {
if (queueMessageRequestIdByMessageId.has(messageId)) {
continue
}
const requestId = pendingQueuedMessageRequestIds.shift()
if (!requestId) {
continue
}
queueMessageRequestIdByMessageId.set(messageId, requestId)
}
}
const promoteRequestIdForDequeuedMessages = (queueMessageIds: string[]) => {
if (lastQueueMessageIds.length === 0) {
return
}
const remainingIds = new Set(queueMessageIds)
for (const dequeuedMessageId of lastQueueMessageIds) {
if (remainingIds.has(dequeuedMessageId)) {
continue
}
const requestId = queueMessageRequestIdByMessageId.get(dequeuedMessageId)
if (requestId) {
setStreamRequestId(requestId)
}
queueMessageRequestIdByMessageId.delete(dequeuedMessageId)
}
}
const waitForPreviousTaskToSettle = async () => {
if (!activeTaskPromise) {
@ -407,6 +444,7 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
const queueMessageIds = queueSnapshot.map((item) => item.id)
if (!hasSeenQueueState) {
assignRequestIdsToNewQueueMessages(queueMessageIds)
hasSeenQueueState = true
lastQueueDepth = queueDepth
lastQueueMessageIds = queueMessageIds
@ -432,6 +470,9 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
return
}
promoteRequestIdForDequeuedMessages(queueMessageIds)
assignRequestIdsToNewQueueMessages(queueMessageIds)
const subtype: "enqueued" | "dequeued" | "drained" | "updated" = depthChanged
? queueDepth > lastQueueDepth
? "enqueued"
@ -481,9 +522,19 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
success: event.success,
})
// If user messages were queued while the task was still running, shift
// event attribution to the oldest pending message request as soon as the
// task turn completes so prompt echo/user feedback events are tagged.
const oldestQueuedMessageId = lastQueueMessageIds[0]
const nextQueuedRequestId =
pendingQueuedMessageRequestIds[0] ??
(oldestQueuedMessageId ? queueMessageRequestIdByMessageId.get(oldestQueuedMessageId) : undefined)
if (nextQueuedRequestId) {
setStreamRequestId(nextQueuedRequestId)
}
activeTaskCommand = undefined
activeRequestId = undefined
setStreamRequestId(undefined)
cancelRequestedForActiveTask = false
}
})
@ -626,8 +677,6 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
break
}
setStreamRequestId(stdinCommand.requestId)
jsonEmitter.emitControl({
subtype: "ack",
requestId: stdinCommand.requestId,
@ -639,6 +688,10 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
})
host.sendToExtension({ type: "queueMessage", text: stdinCommand.prompt })
pendingQueuedMessageRequestIds.push(stdinCommand.requestId)
if (host.isWaitingForInput()) {
setStreamRequestId(stdinCommand.requestId)
}
jsonEmitter.emitControl({
subtype: "done",

View file

@ -80,14 +80,6 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
await task.say("completion_result", result, undefined, false)
// Force final token usage update before emitting TaskCompleted
// This ensures the most recent stats are captured regardless of throttle timer
// and properly updates the snapshot to prevent redundant emissions
task.emitFinalTokenUsageUpdate()
TelemetryService.instance.captureTaskCompleted(task.taskId)
task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage)
// Check for subtask using parentTaskId (metadata-driven delegation)
if (task.parentTaskId) {
// Check if this subtask has already completed and returned to parent
@ -105,14 +97,17 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
// without injecting another tool_result to the parent
} else if (status === "active") {
// Normal subtask completion - do delegation
const delegated = await this.delegateToParent(
const delegation = await this.delegateToParent(
task,
result,
provider,
askFinishSubTaskApproval,
pushToolResult,
)
if (delegated) return
if (delegation === "delegated") {
this.emitTaskCompleted(task)
}
if (delegation !== "continue") return
} else {
// Unexpected status (undefined or "delegated") - log error and skip delegation
// undefined indicates a bug in status persistence during child creation
@ -137,6 +132,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
const { response, text, images } = await task.ask("completion_result", "", false)
if (response === "yesButtonClicked") {
this.emitTaskCompleted(task)
return
}
@ -152,7 +148,10 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
/**
* Handles the common delegation flow when a subtask completes.
* Returns true if delegation was performed and the caller should return early.
* Returns:
* - "delegated" when completion was approved and parent resumed
* - "denied" when user denied finishing the subtask
* - "continue" when caller should fall through to normal completion ask flow
*/
private async delegateToParent(
task: Task,
@ -160,12 +159,12 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
provider: DelegationProvider,
askFinishSubTaskApproval: () => Promise<boolean>,
pushToolResult: (result: string) => void,
): Promise<boolean> {
): Promise<"delegated" | "denied" | "continue"> {
const didApprove = await askFinishSubTaskApproval()
if (!didApprove) {
pushToolResult(formatResponse.toolDenied())
return true
return "denied"
}
pushToolResult("")
@ -176,7 +175,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
completionResultSummary: result,
})
return true
return "delegated"
}
override async handlePartial(task: Task, block: ToolUse<"attempt_completion">): Promise<void> {
@ -190,19 +189,21 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> {
await task.ask("command", command ?? "", block.partial).catch(() => {})
} else {
await task.say("completion_result", result ?? "", undefined, false)
// Force final token usage update before emitting TaskCompleted for consistency
task.emitFinalTokenUsageUpdate()
TelemetryService.instance.captureTaskCompleted(task.taskId)
task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage)
await task.ask("command", command ?? "", block.partial).catch(() => {})
}
} else {
await task.say("completion_result", result ?? "", undefined, block.partial)
}
}
private emitTaskCompleted(task: Task): void {
// Force final token usage update before emitting TaskCompleted.
// This ensures the latest stats are captured regardless of throttle timer.
task.emitFinalTokenUsageUpdate()
TelemetryService.instance.captureTaskCompleted(task.taskId)
task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage)
}
}
export const attemptCompletionTool = new AttemptCompletionTool()

View file

@ -1,4 +1,4 @@
import { TodoItem } from "@roo-code/types"
import { RooCodeEventName, TodoItem } from "@roo-code/types"
import { AttemptCompletionToolUse } from "../../../shared/tools"
@ -6,6 +6,19 @@ import { AttemptCompletionToolUse } from "../../../shared/tools"
vi.mock("../../prompts/responses", () => ({
formatResponse: {
toolError: vi.fn((msg: string) => `Error: ${msg}`),
toolResult: vi.fn((msg: string) => `Result: ${msg}`),
toolDenied: vi.fn(() => "Denied"),
},
}))
const { mockCaptureTaskCompleted } = vi.hoisted(() => ({
mockCaptureTaskCompleted: vi.fn(),
}))
vi.mock("@roo-code/telemetry", () => ({
TelemetryService: {
instance: {
captureTaskCompleted: mockCaptureTaskCompleted,
},
},
}))
@ -39,6 +52,7 @@ describe("attemptCompletionTool", () => {
let mockGetConfiguration: ReturnType<typeof vi.fn>
beforeEach(() => {
mockCaptureTaskCompleted.mockReset()
mockPushToolResult = vi.fn()
mockAskApproval = vi.fn()
mockHandleError = vi.fn()
@ -468,5 +482,74 @@ describe("attemptCompletionTool", () => {
expect(mockTask.recordToolError).not.toHaveBeenCalled()
})
})
describe("completion lifecycle", () => {
it("emits TaskCompleted only when completion is accepted", async () => {
const block: AttemptCompletionToolUse = {
type: "tool_use",
name: "attempt_completion",
params: { result: "2" },
nativeArgs: { result: "2" },
partial: false,
}
mockTask.ask = vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: "", images: [] })
const callbacks: AttemptCompletionCallbacks = {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
askFinishSubTaskApproval: mockAskFinishSubTaskApproval,
toolDescription: mockToolDescription,
}
await attemptCompletionTool.handle(mockTask as Task, block, callbacks)
expect(mockHandleError).not.toHaveBeenCalled()
expect(mockCaptureTaskCompleted).toHaveBeenCalledWith("task_1")
expect(mockTask.emit).toHaveBeenCalledWith(
RooCodeEventName.TaskCompleted,
"task_1",
expect.anything(),
expect.anything(),
)
})
it("does not emit TaskCompleted when user provides follow-up feedback", async () => {
const block: AttemptCompletionToolUse = {
type: "tool_use",
name: "attempt_completion",
params: { result: "2" },
nativeArgs: { result: "2" },
partial: false,
}
mockTask.ask = vi.fn().mockResolvedValue({
response: "messageResponse",
text: "Different question now: what is 3+3?",
images: [],
})
const callbacks: AttemptCompletionCallbacks = {
askApproval: mockAskApproval,
handleError: mockHandleError,
pushToolResult: mockPushToolResult,
askFinishSubTaskApproval: mockAskFinishSubTaskApproval,
toolDescription: mockToolDescription,
}
await attemptCompletionTool.handle(mockTask as Task, block, callbacks)
expect(mockHandleError).not.toHaveBeenCalled()
expect(mockCaptureTaskCompleted).not.toHaveBeenCalled()
expect(mockTask.emit).not.toHaveBeenCalledWith(
RooCodeEventName.TaskCompleted,
expect.anything(),
expect.anything(),
expect.anything(),
)
expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("<user_message>"))
})
})
})
})

View file

@ -2989,7 +2989,20 @@ export class ClineProvider
console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`)
const { historyItem, uiMessagesFilePath } = await this.getTaskWithId(task.taskId)
let historyItem: HistoryItem | undefined
try {
const history = await this.getTaskWithId(task.taskId)
historyItem = history.historyItem
} catch (error) {
// During task startup there is a short window where currentTask exists
// but task history has not been persisted yet. Cancelling should still
// abort safely; we just skip post-cancel rehydration in that case.
if (error instanceof Error && error.message === "Task not found") {
this.log(`[cancelTask] task history missing for ${task.taskId}; skipping rehydrate`)
} else {
throw error
}
}
// Preserve parent and root task information for history item.
const rootTask = task.rootTask
@ -3047,6 +3060,10 @@ export class ClineProvider
}
}
if (!historyItem) {
return
}
// Clears task again, so we need to abortTask manually above.
await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask })
}