Skip to content

Commit 21f71bd

Browse files
committed
fix(chats): enforce stop reconciliation outcomes
1 parent 26be1a1 commit 21f71bd

14 files changed

Lines changed: 655 additions & 22 deletions

common/__tests__/chat-command-contracts.test.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import {
88
parseQueueEntryReplaceCommandRequest,
99
parseStartChatCommandRequest,
1010
} from '../chat-command-contracts.ts';
11+
import {
12+
CHAT_STOP_OUTCOMES,
13+
isAbortAcknowledged,
14+
isStopSatisfied,
15+
} from '../chat-types.ts';
1116

1217
const CHAT_ID = '1783725900000000';
1318
const SOURCE_CHAT_ID = '1783725900000001';
@@ -17,6 +22,18 @@ function agentSettings(ownerId = 'claude') {
1722
}
1823

1924
describe('chat command request parsers', () => {
25+
it('classifies every Stop outcome by command satisfaction and provider acknowledgement', () => {
26+
expect(CHAT_STOP_OUTCOMES.map((outcome) => ({
27+
outcome,
28+
satisfied: isStopSatisfied(outcome),
29+
acknowledged: isAbortAcknowledged(outcome),
30+
}))).toEqual([
31+
{ outcome: 'interrupt-requested', satisfied: true, acknowledged: true },
32+
{ outcome: 'already-idle', satisfied: true, acknowledged: false },
33+
{ outcome: 'failed', satisfied: false, acknowledged: false },
34+
]);
35+
});
36+
2037
it('normalizes start modes and tags once at the wire boundary', () => {
2138
const parsed = parseStartChatCommandRequest({
2239
clientRequestId: ' request-1 ',

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
22
import {
33
accessSync,
44
appendFileSync,
5+
existsSync,
56
mkdirSync,
67
writeFileSync,
78
} from 'node:fs';
@@ -10,6 +11,11 @@ import { join, resolve } from 'node:path';
1011

1112
const MAX_SANITIZED_LENGTH = 200;
1213
const args = process.argv.slice(2);
14+
let heldInput: {
15+
uuid: string;
16+
sessionId: string;
17+
} | null = null;
18+
let heldAbortPoll: ReturnType<typeof setInterval> | null = null;
1319

1420
if (args.includes('--version')) {
1521
console.log('2.1.220 (Claude Code)');
@@ -119,6 +125,20 @@ function handleInput(line: string, nativePath: string, sessionId: string): void
119125
uuid?: string;
120126
message?: { role?: string; content?: unknown };
121127
};
128+
if (input.type === 'control_request' && input.request?.subtype === 'interrupt') {
129+
writeOutput({
130+
type: 'control_response',
131+
response: {
132+
subtype: 'success',
133+
request_id: input.request_id,
134+
response: { cancelled: [], still_queued: [] },
135+
},
136+
});
137+
const interruptPath = process.env.CLAUDE_TEST_INTERRUPT_PATH;
138+
if (interruptPath) appendFileSync(interruptPath, 'interrupt\n');
139+
scheduleHeldAbortSettlement();
140+
return;
141+
}
122142
if (
123143
input.type === 'control_request'
124144
&& (input.request?.subtype === 'initialize' || input.request?.subtype === 'set_model')
@@ -140,6 +160,45 @@ function handleInput(line: string, nativePath: string, sessionId: string): void
140160
const response = `echo:${prompt}`;
141161
const userTimestamp = new Date().toISOString();
142162
const assistantTimestamp = new Date(Date.now() + 1).toISOString();
163+
if (process.env.CLAUDE_TEST_HOLD_ACTIVE === '1') {
164+
appendFileSync(nativePath, `${JSON.stringify({
165+
sessionId,
166+
type: 'user',
167+
uuid: input.uuid,
168+
timestamp: userTimestamp,
169+
cwd: process.cwd(),
170+
message: { role: 'user', content: prompt },
171+
})}\n`);
172+
writeOutput({
173+
type: 'command_lifecycle',
174+
command_uuid: input.uuid,
175+
state: 'queued',
176+
session_id: sessionId,
177+
});
178+
writeOutput({
179+
type: 'system',
180+
subtype: 'session_state_changed',
181+
state: 'running',
182+
session_id: sessionId,
183+
});
184+
writeOutput({
185+
type: 'command_lifecycle',
186+
command_uuid: input.uuid,
187+
state: 'started',
188+
session_id: sessionId,
189+
});
190+
writeOutput({
191+
type: 'user',
192+
uuid: input.uuid,
193+
isReplay: true,
194+
message: input.message,
195+
session_id: sessionId,
196+
});
197+
heldInput = { uuid: input.uuid, sessionId };
198+
const startedPath = process.env.CLAUDE_TEST_STARTED_PATH;
199+
if (startedPath) writeFileSync(startedPath, `${input.uuid}\n`);
200+
return;
201+
}
143202
appendFileSync(nativePath, [
144203
JSON.stringify({
145204
sessionId,
@@ -219,6 +278,42 @@ function handleInput(line: string, nativePath: string, sessionId: string): void
219278
});
220279
}
221280

281+
function scheduleHeldAbortSettlement(): void {
282+
if (!heldInput || heldAbortPoll) return;
283+
const releasePath = process.env.CLAUDE_TEST_RELEASE_PATH;
284+
if (!releasePath) return;
285+
heldAbortPoll = setInterval(() => {
286+
if (!heldInput || !existsSync(releasePath)) return;
287+
const input = heldInput;
288+
heldInput = null;
289+
if (heldAbortPoll) clearInterval(heldAbortPoll);
290+
heldAbortPoll = null;
291+
writeOutput({
292+
type: 'result',
293+
subtype: 'error_during_execution',
294+
terminal_reason: 'aborted_tools',
295+
is_error: true,
296+
duration_ms: 1,
297+
num_turns: 1,
298+
result: '',
299+
user_message_uuid: input.uuid,
300+
session_id: input.sessionId,
301+
});
302+
writeOutput({
303+
type: 'command_lifecycle',
304+
command_uuid: input.uuid,
305+
state: 'cancelled',
306+
session_id: input.sessionId,
307+
});
308+
writeOutput({
309+
type: 'system',
310+
subtype: 'session_state_changed',
311+
state: 'idle',
312+
session_id: input.sessionId,
313+
});
314+
}, 5);
315+
}
316+
222317
function messageText(content: unknown): string {
223318
if (typeof content === 'string') return content;
224319
if (!Array.isArray(content)) return '';

integration-tests/support/integration-fixture.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ export class IntegrationFixture {
306306
clientRequestId?: string;
307307
turnId?: string;
308308
}): Promise<void> {
309-
const nativePath = await this.#directOpenAiNativePath(input.chatId);
309+
const nativePath = await this.directOpenAiNativePath(input.chatId);
310310
const raw = await readFile(nativePath, 'utf8');
311311
if (raw.length > 0 && !raw.endsWith('\n')) {
312312
throw new Error('Direct native transcript has an incomplete tail.');
@@ -320,7 +320,7 @@ export class IntegrationFixture {
320320
})}\n`, 'utf8');
321321
}
322322

323-
async #directOpenAiNativePath(chatId: string): Promise<string> {
323+
async directOpenAiNativePath(chatId: string): Promise<string> {
324324
const registry = JSON.parse(
325325
await readFile(join(this.dirs.workspace, 'chats.json'), 'utf8'),
326326
) as { sessions?: Record<string, Record<string, unknown>> };
@@ -358,7 +358,7 @@ export class IntegrationFixture {
358358
}
359359

360360
async #removeFinalNativeUserRow(input: { chatId: string; clientRequestId: string }): Promise<void> {
361-
const expectedPath = await this.#directOpenAiNativePath(input.chatId);
361+
const expectedPath = await this.directOpenAiNativePath(input.chatId);
362362
const raw = await readFile(expectedPath, 'utf8');
363363
if (!raw.endsWith('\n')) throw new Error('Direct native transcript has an incomplete tail.');
364364
const lines = raw.split('\n').filter((line) => line.length > 0);

0 commit comments

Comments
 (0)