From 60000076055beacdc1982373f9e4c527fc927018 Mon Sep 17 00:00:00 2001 From: Sleeyax Date: Wed, 21 Oct 2026 14:42:44 +0200 Subject: [PATCH] fix(claude-tty): keep a question card open in a turn held for background work A turn held open for a background agent ends once Claude has written nothing for five minutes. When the agent's report woke Claude and it asked a question, Claude wrote nothing while the person answered, so after five minutes the turn ended, the card was cancelled and Claude was told to ask in chat instead. The hold now waits as long as a card Claude is held up behind is open, and counts the five minutes from the moment it is answered. Co-Authored-By: Claude Opus 5.5 --- .../claude-tty-acp/src/claude-runtime.test.ts | 97 +++++++++++++++++++ apps/claude-tty-acp/src/claude-runtime.ts | 5 +- apps/claude-tty-acp/src/interactions.ts | 8 +- 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/apps/claude-tty-acp/src/claude-runtime.test.ts b/apps/claude-tty-acp/src/claude-runtime.test.ts index e6a07ccd..a06018bd 100644 --- a/apps/claude-tty-acp/src/claude-runtime.test.ts +++ b/apps/claude-tty-acp/src/claude-runtime.test.ts @@ -3400,6 +3400,103 @@ test("stops waiting when the agent that reported never wakes Claude to answer fo } }); +test("goes on waiting while the question Claude asked for the agent that reported is still being answered", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "claude-runtime-subagent-question-test-")); + const configDirectory = path.join(root, "claude"); + const runtimeRoot = path.join(root, "runtime"); + const cwd = "/work/agents-question"; + const projectDirectory = path.join(configDirectory, "projects", escapeProjectDirName(cwd)); + await mkdir(projectDirectory, { recursive: true }); + await mkdir(runtimeRoot, { recursive: true }); + let agent!: ClaudeTtyAgent; + let spawned: FakePty | null = null; + let answer: ((response: RequestPermissionResponse) => void) | null = null; + const spawnPty = (_file: string, args: string[]): Pick => { + spawned = new FakePty(3850); + const sessionId = args[args.indexOf("--session-id") + 1]!; + setImmediate(() => void agent.hooks.dispatch({ hook_event_name: "SessionStart", session_id: sessionId })); + return spawned; + }; + const connection = { + sessionUpdate: async () => undefined, + requestPermission: () => new Promise((resolve) => (answer = resolve)), + extNotification: async () => undefined, + } as unknown as AgentSideConnection; + agent = new ClaudeTtyAgent(connection, { + ...TEST_TIMINGS, + spawnPty, + runtimeRoot, + claudeConfigDir: configDirectory, + stateDirectory: path.join(root, "state"), + startupTimeoutMs: 500, + readinessTimeoutMs: 0, + submitDelayMs: 0, + contextRefreshTimeoutMs: 0, + transcriptPollIntervalMs: 10, + subagentPollMs: 10, + subagentWakeMs: 100, + }); + + try { + const session = await agent.newSession({ cwd, mcpServers: [] }); + const turn = agent.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "launch an agent" }] }); + await waitFor(() => spawned !== null && spawned.writes.length === 2); + const transcript = path.join(projectDirectory, `${session.sessionId}.jsonl`); + const records = [ + JSON.stringify({ + type: "assistant", + uuid: "launcher", + message: { content: [{ type: "tool_use", id: "agent-tool", name: "Agent", input: { description: "Count the files" } }] }, + }), + JSON.stringify({ + type: "user", + uuid: "launched", + toolUseResult: { isAsync: true, status: "async_launched", agentId: "a1" }, + message: { content: [{ type: "tool_result", tool_use_id: "agent-tool", content: [] }] }, + }), + ]; + await writeFile(transcript, `${records.join("\n")}\n`); + let settled = false; + void turn.then(() => { + settled = true; + }); + await agent.hooks.dispatch({ hook_event_name: "Stop", session_id: session.sessionId, last_assistant_message: "LAUNCHED" }); + records.push( + JSON.stringify({ + type: "user", + uuid: "notified", + message: { content: " a1 completed done " }, + }), + ); + await writeFile(transcript, `${records.join("\n")}\n`); + + // Claude wakes for the report and asks something, then writes nothing until it is answered. + const question = agent.hooks.dispatch({ + hook_event_name: "PreToolUse", + session_id: session.sessionId, + tool_name: "AskUserQuestion", + tool_use_id: "toolu_held", + tool_input: { questions: [{ question: "Which one?", header: "Pick", options: [{ label: "This one" }] }] }, + }); + await waitFor(() => answer !== null); + await new Promise((resolve) => setTimeout(resolve, 400)); + assert.equal(settled, false); + + answer!({ outcome: { outcome: "selected", optionId: "answer-0-0" } }); + const decision = (await question) as { hookSpecificOutput?: { permissionDecision?: string } }; + assert.equal(decision.hookSpecificOutput?.permissionDecision, "allow"); + // Claude has only just been given the answer, so the wake bound counts from there rather than from the question. + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(settled, false); + + await agent.hooks.dispatch({ hook_event_name: "Stop", session_id: session.sessionId, last_assistant_message: "Going with this one." }); + assert.deepEqual(await turn, { stopReason: "end_turn" }); + } finally { + await agent.close(); + await rm(root, { force: true, recursive: true }); + } +}); + test("goes on waiting while Claude is answering for the agent that reported", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "claude-runtime-subagent-answer-test-")); const configDirectory = path.join(root, "claude"); diff --git a/apps/claude-tty-acp/src/claude-runtime.ts b/apps/claude-tty-acp/src/claude-runtime.ts index 6cafb17e..1e6cbe2d 100644 --- a/apps/claude-tty-acp/src/claude-runtime.ts +++ b/apps/claude-tty-acp/src/claude-runtime.ts @@ -853,6 +853,8 @@ export class ClaudeRuntime { this.releaseBackgroundHold(); return; } + // Claude writes nothing while it waits on a card, so the silence is the person's, however long they take. + if (this.interactions.pending) return; const agents = this.translator.runningSubagents; const shells = this.translator.runningBackgroundShells; const now = Date.now(); @@ -896,12 +898,13 @@ export class ClaudeRuntime { return Math.max(this.translator.backgroundShellActivityAt, this.heldAt); } - /** Claude answering for work that has reported is exactly what the wake bound waits for, so anything Claude writes counts towards it: what it says, what it thinks, and the tools it runs on the way. */ + /** Claude answering for work that has reported is exactly what the wake bound waits for, so anything Claude writes counts towards it: what it says, what it thinks, and the tools it runs on the way, and so does the answer to a card that held it up. */ private answerProgressAt(): number { return Math.max( this.translator.subagentActivityAt, this.translator.backgroundShellActivityAt, this.translator.assistantActivityAt, + this.interactions.answeredAt, this.heldAt, ); } diff --git a/apps/claude-tty-acp/src/interactions.ts b/apps/claude-tty-acp/src/interactions.ts index 4f1e247e..96b75b4a 100644 --- a/apps/claude-tty-acp/src/interactions.ts +++ b/apps/claude-tty-acp/src/interactions.ts @@ -81,6 +81,7 @@ export class InteractionBridge { private readonly blockingRequests = new Set>(); private readonly liveInteractions = new Map>(); private readonly autoAccept: () => Promise; + private lastAnsweredAt = 0; constructor(sessionId: string, cwd: string, connection: AgentSideConnection, autoAccept: () => Promise = async () => false) { this.sessionId = sessionId; @@ -94,6 +95,11 @@ export class InteractionBridge { return this.blockingRequests.size > 0; } + /** When the last card Claude was held up behind was answered, or 0 if none has been. */ + get answeredAt(): number { + return this.lastAnsweredAt; + } + beginTurn(): void { this.pendingTools.length = 0; this.cancelPending(); @@ -333,7 +339,7 @@ export class InteractionBridge { cancellation.promise, ]).finally(() => { this.pendingRequests.delete(cancellation); - this.blockingRequests.delete(cancellation); + if (this.blockingRequests.delete(cancellation)) this.lastAnsweredAt = Date.now(); }); return { response,