Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions apps/claude-tty-acp/src/claude-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IPty, "pid" | "write" | "kill" | "onData" | "onExit"> => {
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<RequestPermissionResponse>((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: "<task-notification> <task-id>a1</task-id> <status>completed</status> <summary>done</summary> </task-notification>" },
}),
);
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");
Expand Down
5 changes: 4 additions & 1 deletion apps/claude-tty-acp/src/claude-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
);
}
Expand Down
8 changes: 7 additions & 1 deletion apps/claude-tty-acp/src/interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export class InteractionBridge {
private readonly blockingRequests = new Set<Deferred<RequestPermissionResponse>>();
private readonly liveInteractions = new Map<string, Promise<InteractionOutcome>>();
private readonly autoAccept: () => Promise<boolean>;
private lastAnsweredAt = 0;

constructor(sessionId: string, cwd: string, connection: AgentSideConnection, autoAccept: () => Promise<boolean> = async () => false) {
this.sessionId = sessionId;
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down
Loading