cli: recover pending questions from plan_exit tool

The question recovery path only matched the "question" tool, so
plan_exit prompts would get stuck when the question.asked event
was missed during reconnection. Extend recovery to match any
tool-owned question request and drop the tool-name guard in
syncQuestion so completions clear the footer correctly.
This commit is contained in:
Simon Klee
2026-05-18 15:23:34 +02:00
parent f7493d41cb
commit cff1c6f333
3 changed files with 123 additions and 13 deletions
@@ -360,14 +360,10 @@ function syncPermission(data: SessionData, part: ToolPart): FooterOutput | undef
}
}
// Question tool replies can complete without a matching question.replied event.
// When that happens, drop the recovered pending request tied to this tool call so
// the footer can return to the next blocker or to the prompt.
// Tool-owned question requests can complete without a matching question.replied
// event. When that happens, drop the recovered pending request tied to this tool
// call so the footer can return to the next blocker or to the prompt.
function syncQuestion(data: SessionData, part: ToolPart): FooterOutput | undefined {
if (part.tool !== "question") {
return undefined
}
if (part.state.status !== "completed" && part.state.status !== "error") {
return undefined
}
@@ -791,7 +791,7 @@ function createLayer(input: StreamInput) {
event.type === "message.part.updated" &&
event.properties.part.sessionID === input.sessionID &&
event.properties.part.type === "tool" &&
event.properties.part.tool === "question" &&
(event.properties.part.tool === "question" || event.properties.part.tool === "plan_exit") &&
event.properties.part.state.status === "running"
) {
yield* recoverQuestion(event.properties.part).pipe(
@@ -845,7 +845,7 @@ describe("run stream transport", () => {
stream: src.stream,
questions: async () => {
questionCalls += 1
return ok(questionCalls === 1 ? [other] : [other, request])
return ok(questionCalls === 1 ? [other] : [request])
},
promptAsync: async () => {
queueMicrotask(() => {
@@ -908,6 +908,7 @@ describe("run stream transport", () => {
},
})
const count = ui.events.length
src.push(
toolUpdated(
completedTool({
@@ -930,13 +931,126 @@ describe("run stream transport", () => {
expect(
await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "question" && item.view.request.id === other.id
? item.view
: undefined
return item?.type === "stream.view" && item.view.type === "prompt" ? item : undefined
}),
).toEqual({
type: "stream.view",
view: { type: "prompt" },
})
expect(
ui.events.slice(count).findLast(
(event) => event.type === "stream.view" && event.view.type === "question" && event.view.request.id === other.id,
),
).toBeUndefined()
ctrl.abort()
await run
} finally {
src.close()
await transport.close()
}
})
test("recovers pending plan_exit questions from question.list when question.asked is missed", async () => {
const src = eventFeed()
const ui = footer()
let questionCalls = 0
const request = {
id: "question-plan-1",
sessionID: "session-1",
questions: [
{
question: "Plan is complete. Start implementing it now?",
header: "Build Agent",
options: [{ label: "Yes", description: "Switch to build agent and start implementing." }],
multiple: false,
},
],
tool: {
messageID: "msg-plan-1",
callID: "call-plan-exit-1",
},
}
const transport = await createSessionTransport({
sdk: sdk({
stream: src.stream,
questions: async () => {
questionCalls += 1
return ok(questionCalls === 1 ? [] : [request])
},
promptAsync: async () => {
queueMicrotask(() => {
src.push(busy())
src.push(assistant("msg-plan-1"))
src.push(
toolUpdated(
runningTool({
sessionID: "session-1",
messageID: "msg-plan-1",
id: "plan-exit-tool-1",
callID: "call-plan-exit-1",
tool: "plan_exit",
body: {},
}),
),
)
})
return ok(undefined)
},
}),
sessionID: "session-1",
thinking: true,
limits: () => ({}),
footer: ui.api,
})
const ctrl = new AbortController()
try {
const run = transport.runPromptTurn({
agent: undefined,
model: undefined,
variant: undefined,
prompt: { text: "hello", parts: [] },
files: [],
includeFiles: false,
signal: ctrl.signal,
})
expect(
await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "question" ? item.view : undefined
}),
).toEqual({
type: "question",
request: other,
request,
})
src.push(
toolUpdated(
completedTool({
sessionID: "session-1",
messageID: "msg-plan-1",
id: "plan-exit-tool-1",
callID: "call-plan-exit-1",
tool: "plan_exit",
body: {},
output: "User approved switching to build agent.",
metadata: {},
}),
),
)
expect(
await waitFor(() => {
const item = ui.events.findLast((event) => event.type === "stream.view")
return item?.type === "stream.view" && item.view.type === "prompt" ? item : undefined
}),
).toEqual({
type: "stream.view",
view: { type: "prompt" },
})
ctrl.abort()