Explorar o código

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

Add stdin stream integration tests and fix startup cancel race
Chris Estreich hai 1 mes
pai
achega
e6ad7949d6

+ 1 - 1
apps/cli/package.json

@@ -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 - 0
apps/cli/scripts/integration/cases/cancel-active-task.ts

@@ -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)
+})

+ 83 - 0
apps/cli/scripts/integration/cases/cancel-immediately-after-start-ack.ts

@@ -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 - 0
apps/cli/scripts/integration/cases/followup-after-completion.ts

@@ -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 - 0
apps/cli/scripts/integration/cases/followup-during-streaming.ts

@@ -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)
+})

+ 51 - 0
apps/cli/scripts/integration/cases/message-without-active-task.ts

@@ -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 - 0
apps/cli/scripts/integration/cases/multi-message-queue-order.ts

@@ -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 - 0
apps/cli/scripts/integration/cases/shutdown-while-running.ts

@@ -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 - 0
apps/cli/scripts/integration/cases/start-while-busy.ts

@@ -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 - 0
apps/cli/scripts/integration/lib/stream-harness.ts

@@ -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 - 0
apps/cli/scripts/integration/run.ts

@@ -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)
+})

+ 0 - 85
apps/cli/scripts/test-stdin-stream.ts

@@ -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)
-})

+ 56 - 3
apps/cli/src/commands/cli/stdin-stream.ts

@@ -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",

+ 22 - 21
src/core/tools/AttemptCompletionTool.ts

@@ -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()

+ 84 - 1
src/core/tools/__tests__/attemptCompletionTool.spec.ts

@@ -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>"))
+			})
+		})
 	})
 })

+ 18 - 1
src/core/webview/ClineProvider.ts

@@ -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 })
 	}