mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
a7cebac2d8
commit
e6ad7949d6
16 changed files with 1280 additions and 112 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
104
apps/cli/scripts/integration/cases/cancel-active-task.ts
Normal file
104
apps/cli/scripts/integration/cases/cancel-active-task.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
101
apps/cli/scripts/integration/cases/followup-after-completion.ts
Normal file
101
apps/cli/scripts/integration/cases/followup-after-completion.ts
Normal 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)
|
||||
})
|
||||
161
apps/cli/scripts/integration/cases/followup-during-streaming.ts
Normal file
161
apps/cli/scripts/integration/cases/followup-during-streaming.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
184
apps/cli/scripts/integration/cases/multi-message-queue-order.ts
Normal file
184
apps/cli/scripts/integration/cases/multi-message-queue-order.ts
Normal 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)
|
||||
})
|
||||
76
apps/cli/scripts/integration/cases/shutdown-while-running.ts
Normal file
76
apps/cli/scripts/integration/cases/shutdown-while-running.ts
Normal 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)
|
||||
})
|
||||
77
apps/cli/scripts/integration/cases/start-while-busy.ts
Normal file
77
apps/cli/scripts/integration/cases/start-while-busy.ts
Normal 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)
|
||||
})
|
||||
151
apps/cli/scripts/integration/lib/stream-harness.ts
Normal file
151
apps/cli/scripts/integration/lib/stream-harness.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
111
apps/cli/scripts/integration/run.ts
Normal file
111
apps/cli/scripts/integration/run.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue