Skip to content

Commit b19b4c4

Browse files
cfalyongkangc
andauthored
Fix chat stop and processing reconciliation (#415)
* fix(claude): await interrupt acknowledgements * fix(queue): settle interrupted turns before draining Closes #388 * test(queue): cover send now settlement lifecycle Strengthens coverage for #388 * fix(agents): hand off active input at delivery boundary Prevents stale predecessor restoration during active-input admission. * fix(codex): transfer runtime metadata on active input * fix(queue): hand off active input attempt ownership * fix(codex): commit active input ownership atomically Persist accepted input before publishing successor turn ownership across the queue, event bus, operation tracker, and Codex runtime. Transfer abortability at the same synchronous boundary and cover failure, restart, and settlement races deterministically. * refactor(codex): preserve runtime architecture budget * fix(claude): correlate interrupt acknowledgements * fix(chats): reconcile stop outcomes and processing phases * fix(claude): settle interrupts when turns finish first * fix(chats): classify and diagnose stop settlement races * fix(web): validate stop response outcomes * fix(web): reconcile chat processing at the root * fix(claude): accept tool abort terminal results * fix(chats): publish resolved stop state before outcomes * fix(claude): propagate interrupt transport failures * fix(chats): reconcile processing snapshots by source * fix(chats): enforce stop reconciliation outcomes * test(claude): verify live interrupt protocol outcomes * test(integration): fail closed on persisted credentials * test(claude): accept UUID-less abort results * test(codex): isolate live API credentials * test(claude): allow SDK abort terminal variants --------- Co-authored-by: yongkangc <chiayongtcac@gmail.com>
1 parent 2eebeb7 commit b19b4c4

135 files changed

Lines changed: 5785 additions & 1219 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/integration-tests.yml

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,35 @@ jobs:
9898
ANTHROPIC_TESTING_KEY: ${{ secrets.ANTHROPIC_TESTING_KEY }}
9999
run: bun run test:live:claude
100100

101+
- name: Verify Claude diagnostics contain no testing credential
102+
id: claude-diagnostics-audit
103+
if: always() && steps.anthropic-key.outputs.available == 'true'
104+
shell: bash
105+
env:
106+
ANTHROPIC_TESTING_KEY: ${{ secrets.ANTHROPIC_TESTING_KEY }}
107+
run: |
108+
echo "::add-mask::$ANTHROPIC_TESTING_KEY"
109+
if [ -d integration-tests/artifacts/server ]; then
110+
set +e
111+
grep --recursive --fixed-strings --files-with-matches \
112+
-- "$ANTHROPIC_TESTING_KEY" integration-tests/artifacts/server >/dev/null 2>&1
113+
audit_status=$?
114+
set -e
115+
if [ "$audit_status" -eq 0 ]; then
116+
echo "safe=false" >> "$GITHUB_OUTPUT"
117+
echo "::error::Live Claude diagnostics contain the testing credential and will not be uploaded."
118+
exit 1
119+
fi
120+
if [ "$audit_status" -ne 1 ]; then
121+
echo "safe=false" >> "$GITHUB_OUTPUT"
122+
echo "::error::Live Claude diagnostics could not be audited and will not be uploaded."
123+
exit 1
124+
fi
125+
fi
126+
echo "safe=true" >> "$GITHUB_OUTPUT"
127+
101128
- name: Upload live Claude diagnostics
102-
if: failure() && steps.anthropic-key.outputs.available == 'true'
129+
if: failure() && steps.anthropic-key.outputs.available == 'true' && steps.claude-diagnostics-audit.outputs.safe == 'true'
103130
uses: actions/upload-artifact@v4
104131
with:
105132
name: claude-live-integration-diagnostics
@@ -152,8 +179,35 @@ jobs:
152179
OPENAI_TESTING_KEY: ${{ secrets.OPENAI_TESTING_KEY }}
153180
run: bun run test:live:codex
154181

182+
- name: Verify Codex diagnostics contain no testing credential
183+
id: codex-diagnostics-audit
184+
if: always() && steps.openai-key.outputs.available == 'true'
185+
shell: bash
186+
env:
187+
OPENAI_TESTING_KEY: ${{ secrets.OPENAI_TESTING_KEY }}
188+
run: |
189+
echo "::add-mask::$OPENAI_TESTING_KEY"
190+
if [ -d integration-tests/artifacts/server ]; then
191+
set +e
192+
grep --recursive --fixed-strings --files-with-matches \
193+
-- "$OPENAI_TESTING_KEY" integration-tests/artifacts/server >/dev/null 2>&1
194+
audit_status=$?
195+
set -e
196+
if [ "$audit_status" -eq 0 ]; then
197+
echo "safe=false" >> "$GITHUB_OUTPUT"
198+
echo "::error::Live Codex diagnostics contain the testing credential and will not be uploaded."
199+
exit 1
200+
fi
201+
if [ "$audit_status" -ne 1 ]; then
202+
echo "safe=false" >> "$GITHUB_OUTPUT"
203+
echo "::error::Live Codex diagnostics could not be audited and will not be uploaded."
204+
exit 1
205+
fi
206+
fi
207+
echo "safe=true" >> "$GITHUB_OUTPUT"
208+
155209
- name: Upload live Codex diagnostics
156-
if: failure() && steps.openai-key.outputs.available == 'true'
210+
if: failure() && steps.openai-key.outputs.available == 'true' && steps.codex-diagnostics-audit.outputs.safe == 'true'
157211
uses: actions/upload-artifact@v4
158212
with:
159213
name: codex-live-integration-diagnostics

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 ',

common/chat-command-contracts.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { ChatListEntry } from './chat-list.js';
1414
import type { ErrorCode } from './error-codes.js';
1515
import { normalizeTags } from './tags.js';
1616
import { InvalidChatIdError, parseChatId } from './chat-id.js';
17+
import type { ChatStopOutcome } from './chat-types.js';
1718

1819
export type CommandStatus = 'accepted' | 'duplicate';
1920

@@ -244,7 +245,7 @@ export interface AgentStopCommandRequest {
244245
}
245246

246247
export interface AgentStopResponse extends CommandAcceptedResponse {
247-
stopped: boolean;
248+
outcome: ChatStopOutcome;
248249
control: ChatExecutionControlState;
249250
}
250251

@@ -255,7 +256,7 @@ export interface AgentInterruptAndSendCommandRequest {
255256
}
256257

257258
export interface AgentInterruptAndSendResponse extends CommandAcceptedResponse {
258-
stopped: boolean;
259+
outcome: ChatStopOutcome;
259260
control: ChatExecutionControlState;
260261
}
261262

common/chat-list.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ApiProtocol } from './api-providers.js';
22
import type { PermissionMode, ThinkingMode } from './chat-modes.js';
33
import type { AgentSettingsEnvelope } from './agent-integration.js';
4+
import type { ChatProcessingPhase } from './chat-types.js';
45

56
export interface ChatListEntry {
67
id: string;
@@ -29,6 +30,8 @@ export interface ChatListEntry {
2930
isPinned: boolean;
3031
isArchived: boolean;
3132
isActive: boolean;
33+
isProcessing: boolean;
34+
processingPhase: ChatProcessingPhase | null;
3235
isUnread: boolean;
3336
}
3437

common/chat-types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,27 @@ export interface ChatImage {
1010

1111
export type UserMessageDeliveryStatus = 'submitting' | 'accepted' | 'unconfirmed' | 'failed';
1212
export type ChatStopIntent = 'stop' | 'interrupt-and-send' | 'chat-deletion';
13+
export const CHAT_STOP_OUTCOMES = [
14+
'interrupt-requested',
15+
'already-idle',
16+
'failed',
17+
] as const;
18+
export type ChatStopOutcome = typeof CHAT_STOP_OUTCOMES[number];
19+
export const CHAT_PROCESSING_PHASES = ['running', 'stopping'] as const;
20+
export type ChatProcessingPhase = typeof CHAT_PROCESSING_PHASES[number];
21+
22+
export interface ChatProcessingEntry {
23+
chatId: string;
24+
phase: ChatProcessingPhase;
25+
}
26+
27+
export function isStopSatisfied(outcome: ChatStopOutcome): boolean {
28+
return outcome !== 'failed';
29+
}
30+
31+
export function isAbortAcknowledged(outcome: ChatStopOutcome): boolean {
32+
return outcome === 'interrupt-requested';
33+
}
1334

1435
export interface ChatMessageMetadata {
1536
clientRequestId?: string;

common/ws-events.ts

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import type { ChatGenerationResetReason, ChatViewMessage } from './chat-view';
22
import { parseChatViewMessages } from './chat-view';
3-
import type { ChatStopIntent, UserMessageDeliveryStatus } from './chat-types';
3+
import {
4+
CHAT_STOP_OUTCOMES,
5+
CHAT_PROCESSING_PHASES,
6+
type ChatProcessingEntry,
7+
type ChatProcessingPhase,
8+
type ChatStopIntent,
9+
type ChatStopOutcome,
10+
type UserMessageDeliveryStatus,
11+
} from './chat-types';
412
import type {
513
PendingUserInput,
614
PendingUserInputClearReason,
@@ -120,7 +128,7 @@ export class ChatSessionStoppedMessage {
120128
readonly type = 'chat-session-stopped' as const;
121129
constructor(
122130
public chatId: string,
123-
public success: boolean,
131+
public outcome: ChatStopOutcome,
124132
public intent: ChatStopIntent,
125133
) {}
126134
}
@@ -129,7 +137,7 @@ export class ChatProcessingUpdatedMessage {
129137
readonly type = 'chat-processing-updated' as const;
130138
constructor(
131139
public chatId: string,
132-
public isProcessing: boolean,
140+
public phase: ChatProcessingPhase | null,
133141
) {}
134142
}
135143

@@ -155,14 +163,14 @@ export type ReconnectControlResult =
155163
| { chatId: string; outcome: 'not-found' }
156164
| { chatId: string; outcome: 'unavailable' };
157165

158-
export type ReconnectProcessingResult =
159-
| { outcome: 'snapshot'; runningChatIds: string[] }
166+
export type ChatProcessingSnapshotResult =
167+
| { outcome: 'snapshot'; chats: ChatProcessingEntry[] }
160168
| { outcome: 'unavailable' };
161169

162170
export class ReconnectStateMessage {
163171
readonly type = 'reconnect-state' as const;
164172
constructor(
165-
public processing: ReconnectProcessingResult,
173+
public processing: ChatProcessingSnapshotResult,
166174
public controlResults: ReconnectControlResult[],
167175
public clientRequestId?: string,
168176
) {}
@@ -202,6 +210,7 @@ export class WsPongMessage {
202210
public clientRequestId: string,
203211
public sentAt: number,
204212
public serverTime: string,
213+
public processing: ChatProcessingSnapshotResult,
205214
) {}
206215
}
207216

@@ -378,24 +387,26 @@ function reconnectControlResults(value: unknown): ReconnectControlResult[] | nul
378387
return results;
379388
}
380389

381-
function reconnectProcessingResult(value: unknown): ReconnectProcessingResult | null {
390+
function chatProcessingSnapshotResult(value: unknown): ChatProcessingSnapshotResult | null {
382391
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
383392

384393
const result = value as Record<string, unknown>;
385394
if (result.outcome === 'unavailable') return { outcome: 'unavailable' };
386-
if (result.outcome !== 'snapshot' || !Array.isArray(result.runningChatIds)) return null;
395+
if (result.outcome !== 'snapshot' || !Array.isArray(result.chats)) return null;
387396

388-
const runningChatIds: string[] = [];
397+
const chats: ChatProcessingEntry[] = [];
389398
const seen = new Set<string>();
390-
for (const valueChatId of result.runningChatIds) {
391-
const chatId = requiredStr(valueChatId);
392-
if (!chatId) return null;
393-
if (seen.has(chatId)) continue;
399+
for (const valueEntry of result.chats) {
400+
if (!valueEntry || typeof valueEntry !== 'object' || Array.isArray(valueEntry)) return null;
401+
const entry = valueEntry as Record<string, unknown>;
402+
const chatId = requiredStr(entry.chatId);
403+
const phase = CHAT_PROCESSING_PHASES.find((valuePhase) => valuePhase === entry.phase);
404+
if (!chatId || !phase || seen.has(chatId)) return null;
394405
seen.add(chatId);
395-
runningChatIds.push(chatId);
406+
chats.push({ chatId, phase });
396407
}
397408

398-
return { outcome: 'snapshot', runningChatIds };
409+
return { outcome: 'snapshot', chats };
399410
}
400411

401412
function hasField(data: Record<string, unknown>, key: string): boolean {
@@ -584,18 +595,22 @@ export function parseServerWsMessage(
584595
case 'chat-session-stopped': {
585596
const chatId = requiredStr(data.chatId);
586597
const intent = data.intent;
598+
const outcome = CHAT_STOP_OUTCOMES.find((entry) => entry === data.outcome);
587599
return chatId && (
588600
intent === 'stop'
589601
|| intent === 'interrupt-and-send'
590602
|| intent === 'chat-deletion'
591-
)
592-
? new ChatSessionStoppedMessage(chatId, Boolean(data.success), intent)
603+
) && outcome
604+
? new ChatSessionStoppedMessage(chatId, outcome, intent)
593605
: null;
594606
}
595607
case 'chat-processing-updated': {
596608
const chatId = requiredStr(data.chatId);
597-
return chatId
598-
? new ChatProcessingUpdatedMessage(chatId, Boolean(data.isProcessing))
609+
const phase = data.phase === null
610+
? null
611+
: CHAT_PROCESSING_PHASES.find((entry) => entry === data.phase);
612+
return chatId && phase !== undefined
613+
? new ChatProcessingUpdatedMessage(chatId, phase)
599614
: null;
600615
}
601616
case 'chat-execution-control-updated': {
@@ -617,7 +632,7 @@ export function parseServerWsMessage(
617632
: null;
618633
}
619634
case 'reconnect-state': {
620-
const processing = reconnectProcessingResult(data.processing);
635+
const processing = chatProcessingSnapshotResult(data.processing);
621636
const controlResults = reconnectControlResults(data.controlResults);
622637
if (!processing || !controlResults) return null;
623638
return new ReconnectStateMessage(
@@ -664,8 +679,9 @@ export function parseServerWsMessage(
664679
? data.sentAt
665680
: null;
666681
const serverTime = requiredStr(data.serverTime);
667-
return clientRequestId && sentAt !== null && serverTime
668-
? new WsPongMessage(clientRequestId, sentAt, serverTime)
682+
const processing = chatProcessingSnapshotResult(data.processing);
683+
return clientRequestId && sentAt !== null && serverTime && processing
684+
? new WsPongMessage(clientRequestId, sentAt, serverTime, processing)
669685
: null;
670686
}
671687
case 'chat-title-updated': {

0 commit comments

Comments
 (0)