Fix CLI follow-up routing after completion asks (#11844)

Fix stdin follow-up routing for completion asks in CLI stream mode
This commit is contained in:
Chris Estreich 2026-03-02 22:52:39 -08:00 committed by GitHub
parent 02598bc4a5
commit 06afe4206b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 206 additions and 7 deletions

View file

@ -0,0 +1,143 @@
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".'
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 startAckCount = 0
let sawStartControlAfterFollowup = false
let followupDoneCode: string | undefined
let sawFollowupUserTurn = false
let sawMisroutedToolResult = false
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: 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 (event.type === "control" && event.command === "start" && event.subtype === "ack") {
startAckCount += 1
if (sentFollowup) {
sawStartControlAfterFollowup = true
}
return
}
if (
event.type === "control" &&
event.command === "message" &&
event.subtype === "done" &&
event.requestId === followupRequestId
) {
followupDoneCode = event.code
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 ?? ""
if (followupResult.trim().length === 0) {
throw new Error("follow-up produced an empty result")
}
if (followupDoneCode !== "responded") {
throw new Error(
`follow-up message was not routed as ask response; code="${followupDoneCode ?? "none"}"`,
)
}
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")
}
if (sawStartControlAfterFollowup) {
throw new Error("unexpected start control event after follow-up; message should not trigger a new task")
}
if (startAckCount !== 1) {
throw new Error(`expected exactly one start ack event, saw ${startAckCount}`)
}
console.log(`[PASS] follow-up control code: "${followupDoneCode}"`)
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 completion ask-response follow-up validation",
`initSeen=${initSeen}`,
`sentFollowup=${sentFollowup}`,
`startAckCount=${startAckCount}`,
`followupDoneCode=${followupDoneCode ?? "none"}`,
`sawFollowupUserTurn=${sawFollowupUserTurn}`,
`sawMisroutedToolResult=${sawMisroutedToolResult}`,
`haveFollowupResult=${Boolean(followupResult)}`,
].join(" ")
},
})
}
main().catch((error) => {
console.error(`[FAIL] ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})

View file

@ -16,11 +16,9 @@ function looksLikeAttemptCompletionToolUse(event: StreamEvent): boolean {
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}"`)
function validateFollowupResult(text: string): void {
if (text.trim().length === 0) {
throw new Error("follow-up produced an empty result")
}
}
@ -117,7 +115,7 @@ async function main() {
}
followupResult = event.content ?? ""
validateFollowupAnswer(followupResult)
validateFollowupResult(followupResult)
if (sawMisroutedToolResult) {
throw new Error("follow-up message was misrouted into tool_result (<user_message>), old bug reproduced")

View file

@ -1,4 +1,4 @@
import { parseStdinStreamCommand } from "../stdin-stream.js"
import { parseStdinStreamCommand, shouldSendMessageAsAskResponse } from "../stdin-stream.js"
describe("parseStdinStreamCommand", () => {
describe("valid commands", () => {
@ -162,3 +162,22 @@ describe("parseStdinStreamCommand", () => {
})
})
})
describe("shouldSendMessageAsAskResponse", () => {
it("routes completion_result asks as ask responses", () => {
expect(shouldSendMessageAsAskResponse(true, "completion_result")).toBe(true)
})
it("routes followup asks as ask responses", () => {
expect(shouldSendMessageAsAskResponse(true, "followup")).toBe(true)
})
it("does not route when not waiting for input", () => {
expect(shouldSendMessageAsAskResponse(false, "completion_result")).toBe(false)
})
it("does not route unknown asks", () => {
expect(shouldSendMessageAsAskResponse(true, "unknown")).toBe(false)
expect(shouldSendMessageAsAskResponse(true, undefined)).toBe(false)
})
})

View file

@ -214,6 +214,20 @@ const STDIN_EOF_RESUME_WAIT_TIMEOUT_MS = 2_000
const STDIN_EOF_POLL_INTERVAL_MS = 100
const STDIN_EOF_IDLE_ASKS = new Set(["completion_result", "resume_completed_task"])
const STDIN_EOF_IDLE_STABLE_POLLS = 2
const MESSAGE_AS_ASK_RESPONSE_ASKS = new Set([
"followup",
"tool",
"command",
"use_mcp_server",
"completion_result",
"resume_task",
"resume_completed_task",
"mistake_limit_reached",
])
export function shouldSendMessageAsAskResponse(waitingForInput: boolean, currentAsk: string | undefined): boolean {
return waitingForInput && typeof currentAsk === "string" && MESSAGE_AS_ASK_RESPONSE_ASKS.has(currentAsk)
}
function isResumableState(host: ExtensionHost): boolean {
const agentState = host.client.getAgentState()
@ -690,6 +704,8 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
}
const wasResumable = isResumableState(host)
const currentAsk = host.client.getCurrentAsk()
const shouldSendAsAskResponse = shouldSendMessageAsAskResponse(host.isWaitingForInput(), currentAsk)
if (!host.client.hasActiveTask()) {
jsonEmitter.emitControl({
@ -715,6 +731,29 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
success: true,
})
if (shouldSendAsAskResponse) {
// Match webview behavior: if there is an active ask, route message directly as an ask response.
host.sendToExtension({
type: "askResponse",
askResponse: "messageResponse",
text: stdinCommand.prompt,
images: stdinCommand.images,
})
setStreamRequestId(stdinCommand.requestId)
jsonEmitter.emitControl({
subtype: "done",
requestId: stdinCommand.requestId,
command: "message",
taskId: latestTaskId,
content: "message sent to current ask",
code: "responded",
success: true,
})
awaitingPostCancelRecovery = false
break
}
host.sendToExtension({
type: "queueMessage",
text: stdinCommand.prompt,