Skip to content

Commit 2899ddc

Browse files
authored
Keep Claude turns active through background continuations (#411)
* fix(claude): wait for provider idle before settling * test(claude): cover background continuation ownership * fix(claude): fence background task continuations * fix(claude): handle fast background completions * fix(claude): settle interrupted background waits * fix(claude): retire interrupted background tasks
1 parent 7d6199b commit 2899ddc

9 files changed

Lines changed: 1125 additions & 60 deletions

File tree

integration-tests/support/fake-claude-cli.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,12 @@ function handleInput(line: string, nativePath: string, sessionId: string): void
169169
state: 'queued',
170170
session_id: sessionId,
171171
});
172+
writeOutput({
173+
type: 'system',
174+
subtype: 'session_state_changed',
175+
state: 'running',
176+
session_id: sessionId,
177+
});
172178
writeOutput({
173179
type: 'command_lifecycle',
174180
command_uuid: input.uuid,
@@ -205,6 +211,12 @@ function handleInput(line: string, nativePath: string, sessionId: string): void
205211
state: 'completed',
206212
session_id: sessionId,
207213
});
214+
writeOutput({
215+
type: 'system',
216+
subtype: 'session_state_changed',
217+
state: 'idle',
218+
session_id: sessionId,
219+
});
208220
}
209221

210222
function messageText(content: unknown): string {

integration-tests/tests/live/claude/lifecycle.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,93 @@ import {
3131
} from '../../../support/live-claude.js';
3232

3333
describe('live Claude lifecycle', () => {
34+
test('holds queue ownership across a background Bash continuation', async () => {
35+
const serverEnvironment = await liveClaudeServerEnvironment();
36+
await withIntegrationFixture('live-claude-background-continuation', async (fixture) => {
37+
const chatId = fixture.newChatId();
38+
const launchedMarker = marker('BACKGROUND_LAUNCHED');
39+
const completedMarker = marker('BACKGROUND_COMPLETED');
40+
const successorMarker = marker('BACKGROUND_SUCCESSOR');
41+
const prompt = [
42+
'Use the Bash tool exactly once with run_in_background set to true.',
43+
`Run exactly \`sleep 20; printf done\`.`,
44+
`As soon as Bash reports that the command started in the background, reply with exactly ${launchedMarker} and end that model turn.`,
45+
'Do not call TaskOutput, poll, sleep in another tool, or wait synchronously.',
46+
`When the background task completion notification arrives, reply with exactly ${completedMarker}.`,
47+
].join(' ');
48+
const cursor = fixture.client.markEvents();
49+
const first = await fixture.client.startChat(liveClaudeStartRequest({
50+
chatId,
51+
projectPath: fixture.dirs.project,
52+
command: prompt,
53+
permissionMode: 'bypassPermissions',
54+
}));
55+
56+
await fixture.client.waitForEvent(
57+
(event): event is ChatMessagesMessage =>
58+
event.type === 'chat-messages'
59+
&& event.chatId === chatId
60+
&& event.messages.some((entry) =>
61+
entry.message.type === 'assistant-message'
62+
&& entry.message.content.includes(launchedMarker)),
63+
'live Claude background launch response',
64+
{ afterIndex: cursor, timeoutMs: TURN_TIMEOUT_MS },
65+
);
66+
expect(fixture.client.eventsSince(cursor)).not.toContainEqual(expect.objectContaining({
67+
type: 'agent-run-finished',
68+
chatId,
69+
turnId: first.turnId,
70+
}));
71+
72+
const queueCursor = fixture.client.markEvents();
73+
const successorPrompt = exactReplyPrompt(successorMarker);
74+
const queued = await fixture.client.enqueueNew(chatId, successorPrompt);
75+
expect(queued.control.queue.entries.map((entry) => entry.content)).toEqual([
76+
successorPrompt,
77+
]);
78+
79+
await fixture.client.waitForEvent(
80+
(event): event is ChatMessagesMessage =>
81+
event.type === 'chat-messages'
82+
&& event.chatId === chatId
83+
&& event.messages.some((entry) =>
84+
entry.message.type === 'assistant-message'
85+
&& entry.message.content.includes(completedMarker)),
86+
'live Claude background completion response',
87+
{ afterIndex: cursor, timeoutMs: TURN_TIMEOUT_MS },
88+
);
89+
expectFinished((await fixture.client.waitForTurnTerminal(chatId, first.turnId, {
90+
afterIndex: cursor,
91+
timeoutMs: TURN_TIMEOUT_MS,
92+
})).type);
93+
94+
const successor = await fixture.client.waitForEvent(
95+
(event): event is PendingUserInputUpdatedMessage =>
96+
event.type === 'pending-user-input-updated'
97+
&& event.input.chatId === chatId
98+
&& event.input.content === successorPrompt
99+
&& typeof event.input.turnId === 'string',
100+
'live Claude post-background successor identity',
101+
{ afterIndex: queueCursor, timeoutMs: TURN_TIMEOUT_MS },
102+
);
103+
expectFinished((await fixture.client.waitForTurnTerminal(
104+
chatId,
105+
successor.input.turnId,
106+
{ afterIndex: queueCursor, timeoutMs: TURN_TIMEOUT_MS },
107+
)).type);
108+
109+
const transcript = await fixture.client.getMessages(chatId);
110+
const contents = assistantContents(transcript.messages);
111+
const launchedIndex = contents.findIndex((content) => content.includes(launchedMarker));
112+
const completedIndex = contents.findIndex((content) => content.includes(completedMarker));
113+
const successorIndex = contents.findIndex((content) => content.includes(successorMarker));
114+
expect(launchedIndex).toBeGreaterThanOrEqual(0);
115+
expect(completedIndex).toBeGreaterThan(launchedIndex);
116+
expect(successorIndex).toBeGreaterThan(completedIndex);
117+
expect((await fixture.client.getExecutionControl(chatId)).queue.entries).toEqual([]);
118+
}, { redactSensitiveDiagnostics: true, serverEnvironment });
119+
});
120+
34121
test('queues consecutive turns, forks immediately, and forks the fork', async () => {
35122
const serverEnvironment = await liveClaudeServerEnvironment();
36123
await withIntegrationFixture('live-claude-queue-and-fork', async (fixture) => {

0 commit comments

Comments
 (0)