Skip to content

Commit e6ad794

Browse files
authored
Fix stdin-stream cancel race and add integration test suite (#11817)
Add stdin stream integration tests and fix startup cancel race
1 parent a7cebac commit e6ad794

16 files changed

Lines changed: 1280 additions & 112 deletions

apps/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@
1313
"lint": "eslint src --ext .ts --max-warnings=0",
1414
"check-types": "tsc --noEmit",
1515
"test": "vitest run",
16+
"test:integration": "tsx scripts/integration/run.ts",
1617
"build": "tsup",
1718
"build:extension": "pnpm --filter roo-cline bundle",
1819
"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",
1920
"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",
20-
"dev:test-stdin": "tsx scripts/test-stdin-stream.ts",
2121
"clean": "rimraf dist .turbo"
2222
},
2323
"dependencies": {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
2+
3+
const LONG_PROMPT =
4+
'Run exactly this command and do not summarize until it finishes: sleep 12 && echo "done". After it finishes, reply with exactly "done".'
5+
6+
async function main() {
7+
const startRequestId = `start-a-${Date.now()}`
8+
const cancelRequestId = `cancel-${Date.now()}`
9+
const shutdownRequestId = `shutdown-${Date.now()}`
10+
11+
let initSeen = false
12+
let startAccepted = false
13+
let startCommandToolUseSeen = false
14+
let sentCancel = false
15+
let cancelDone = false
16+
let sentShutdown = false
17+
18+
await runStreamCase({
19+
onEvent(event: StreamEvent, context) {
20+
if (event.type === "system" && event.subtype === "init" && !initSeen) {
21+
initSeen = true
22+
context.sendCommand({
23+
command: "start",
24+
requestId: startRequestId,
25+
prompt: LONG_PROMPT,
26+
})
27+
return
28+
}
29+
30+
if (
31+
event.type === "control" &&
32+
event.subtype === "ack" &&
33+
event.command === "start" &&
34+
event.requestId === startRequestId
35+
) {
36+
startAccepted = true
37+
return
38+
}
39+
40+
if (
41+
event.type === "tool_use" &&
42+
event.subtype === "command" &&
43+
event.done === true &&
44+
event.requestId === startRequestId
45+
) {
46+
startCommandToolUseSeen = true
47+
}
48+
49+
if (startAccepted && startCommandToolUseSeen && !sentCancel) {
50+
context.sendCommand({
51+
command: "cancel",
52+
requestId: cancelRequestId,
53+
})
54+
sentCancel = true
55+
return
56+
}
57+
58+
if (
59+
event.type === "control" &&
60+
event.subtype === "done" &&
61+
event.command === "cancel" &&
62+
event.requestId === cancelRequestId
63+
) {
64+
if (event.code === "cancel_requested" || event.code === "no_active_task") {
65+
cancelDone = true
66+
}
67+
return
68+
}
69+
70+
if (cancelDone && !sentShutdown) {
71+
context.sendCommand({
72+
command: "shutdown",
73+
requestId: shutdownRequestId,
74+
})
75+
sentShutdown = true
76+
return
77+
}
78+
79+
if (event.type === "control" && event.subtype === "error" && event.requestId === cancelRequestId) {
80+
throw new Error(
81+
`cancel command failed with code=${event.code ?? "unknown"} content="${event.content ?? ""}"`,
82+
)
83+
}
84+
85+
if (event.type === "error") {
86+
throw new Error(`unexpected stream error event: ${event.content ?? "unknown error"}`)
87+
}
88+
},
89+
onTimeoutMessage() {
90+
return `timed out waiting for cancel flow (initSeen=${initSeen}, startAccepted=${startAccepted}, startCommandToolUseSeen=${startCommandToolUseSeen}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`
91+
},
92+
})
93+
94+
if (!startAccepted || !startCommandToolUseSeen || !sentCancel || !cancelDone || !sentShutdown) {
95+
throw new Error(
96+
`cancel flow did not complete expected transitions (startAccepted=${startAccepted}, startCommandToolUseSeen=${startCommandToolUseSeen}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`,
97+
)
98+
}
99+
}
100+
101+
main().catch((error) => {
102+
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
103+
process.exit(1)
104+
})
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
2+
3+
const LONG_PROMPT =
4+
'Run exactly this command and do not summarize until it finishes: sleep 12 && echo "done". After it finishes, reply with exactly "done".'
5+
6+
async function main() {
7+
const startRequestId = `start-${Date.now()}`
8+
const cancelRequestId = `cancel-${Date.now()}`
9+
const shutdownRequestId = `shutdown-${Date.now()}`
10+
11+
let initSeen = false
12+
let startAccepted = false
13+
let sentCancel = false
14+
let cancelDone = false
15+
let sentShutdown = false
16+
17+
await runStreamCase({
18+
onEvent(event: StreamEvent, context) {
19+
if (event.type === "system" && event.subtype === "init" && !initSeen) {
20+
initSeen = true
21+
context.sendCommand({
22+
command: "start",
23+
requestId: startRequestId,
24+
prompt: LONG_PROMPT,
25+
})
26+
return
27+
}
28+
29+
if (
30+
event.type === "control" &&
31+
event.subtype === "ack" &&
32+
event.command === "start" &&
33+
event.requestId === startRequestId &&
34+
!startAccepted
35+
) {
36+
startAccepted = true
37+
context.sendCommand({
38+
command: "cancel",
39+
requestId: cancelRequestId,
40+
})
41+
sentCancel = true
42+
return
43+
}
44+
45+
if (
46+
event.type === "control" &&
47+
event.subtype === "done" &&
48+
event.command === "cancel" &&
49+
event.requestId === cancelRequestId
50+
) {
51+
if (event.code === "cancel_requested" || event.code === "no_active_task") {
52+
cancelDone = true
53+
if (!sentShutdown) {
54+
context.sendCommand({
55+
command: "shutdown",
56+
requestId: shutdownRequestId,
57+
})
58+
sentShutdown = true
59+
}
60+
}
61+
return
62+
}
63+
64+
if (event.type === "error") {
65+
throw new Error(`unexpected stream error event: ${event.content ?? "unknown error"}`)
66+
}
67+
},
68+
onTimeoutMessage() {
69+
return `timed out waiting for immediate-cancel flow (initSeen=${initSeen}, startAccepted=${startAccepted}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`
70+
},
71+
})
72+
73+
if (!startAccepted || !sentCancel || !cancelDone || !sentShutdown) {
74+
throw new Error(
75+
`immediate-cancel flow did not complete expected transitions (startAccepted=${startAccepted}, sentCancel=${sentCancel}, cancelDone=${cancelDone}, sentShutdown=${sentShutdown})`,
76+
)
77+
}
78+
}
79+
80+
main().catch((error) => {
81+
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
82+
process.exit(1)
83+
})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { runStreamCase, StreamEvent } from "../lib/stream-harness"
2+
3+
const FIRST_PROMPT = `What is 1+1? Reply with only "2".`
4+
const FOLLOWUP_PROMPT = `Different question now: what is 3+3? Reply with only "6".`
5+
6+
function parseEventContent(text: string | undefined): string {
7+
return typeof text === "string" ? text : ""
8+
}
9+
10+
function validateFollowupAnswer(text: string): void {
11+
const normalized = text.toLowerCase()
12+
const containsExpected = /\b6\b/.test(normalized) || normalized.includes("six")
13+
const containsOldAnswer = /\b1\+1\b/.test(normalized) || /\b2\b/.test(normalized)
14+
const containsQuestionReference = normalized.includes("3+3")
15+
16+
if (!containsExpected) {
17+
throw new Error(`follow-up result did not answer the follow-up question; result="${text}"`)
18+
}
19+
20+
if (!containsQuestionReference && containsOldAnswer && !containsExpected) {
21+
throw new Error(`follow-up result appears anchored to first question; result="${text}"`)
22+
}
23+
}
24+
25+
async function main() {
26+
const startRequestId = `start-${Date.now()}`
27+
const followupRequestId = `message-${Date.now()}`
28+
const shutdownRequestId = `shutdown-${Date.now()}`
29+
30+
let initSeen = false
31+
let sentFollowup = false
32+
let sentShutdown = false
33+
let firstResult = ""
34+
let followupResult = ""
35+
36+
await runStreamCase({
37+
onEvent(event: StreamEvent, context) {
38+
if (event.type === "system" && event.subtype === "init" && !initSeen) {
39+
initSeen = true
40+
context.sendCommand({
41+
command: "start",
42+
requestId: startRequestId,
43+
prompt: FIRST_PROMPT,
44+
})
45+
return
46+
}
47+
48+
if (event.type === "control" && event.subtype === "error") {
49+
throw new Error(
50+
`received control error for requestId=${event.requestId ?? "unknown"} command=${event.command ?? "unknown"} code=${event.code ?? "unknown"} content=${event.content ?? ""}`,
51+
)
52+
}
53+
54+
if (event.type !== "result" || event.done !== true) {
55+
return
56+
}
57+
58+
if (event.requestId === startRequestId) {
59+
firstResult = parseEventContent(event.content)
60+
if (!/\b2\b/.test(firstResult)) {
61+
throw new Error(`first result did not answer first prompt; result="${firstResult}"`)
62+
}
63+
64+
if (!sentFollowup) {
65+
context.sendCommand({
66+
command: "message",
67+
requestId: followupRequestId,
68+
prompt: FOLLOWUP_PROMPT,
69+
})
70+
sentFollowup = true
71+
}
72+
return
73+
}
74+
75+
if (event.requestId !== followupRequestId) {
76+
return
77+
}
78+
79+
followupResult = parseEventContent(event.content)
80+
validateFollowupAnswer(followupResult)
81+
console.log(`[PASS] first result="${firstResult}"`)
82+
console.log(`[PASS] follow-up result="${followupResult}"`)
83+
84+
if (!sentShutdown) {
85+
context.sendCommand({
86+
command: "shutdown",
87+
requestId: shutdownRequestId,
88+
})
89+
sentShutdown = true
90+
}
91+
},
92+
onTimeoutMessage() {
93+
return `timed out waiting for completion (initSeen=${initSeen}, sentFollowup=${sentFollowup}, firstResult=${Boolean(firstResult)}, followupResult=${Boolean(followupResult)})`
94+
},
95+
})
96+
}
97+
98+
main().catch((error) => {
99+
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
100+
process.exit(1)
101+
})

0 commit comments

Comments
 (0)