fix(web-shell): keep turn settlement daemon-authoritative

This commit is contained in:
yiliang114 committed 2026-09-08 20:31:51 +08:00
1 parent 2a7d82fec1
commit 246d9cbe13
4 files changed
+13 -165

No files matched your search

+2 -1
View File
@@ -1292,7 +1292,8 @@ export interface WebShellProps {
onSessionChange?: (event: SessionChangeEvent) => void;
/**
* Called for authoritative terminals observed live, or replayed for a prompt
* this provider admitted.
* this provider admitted. Multiple mounted providers can report the same
* `(sessionId, promptId)`, so hosts should deduplicate by that key.
*/
onAssistantTurnSettled?: (event: WebShellAssistantTurnSettledEvent) => void;
/**
@@ -35,6 +35,17 @@ function getSettledAssistantMessage(
)
.map((block) => block.id),
);
if (
promptBlockIds.size === 0 ||
blocks.some(
(block) =>
block.kind === 'assistant' &&
promptBlockIds.has(block.id) &&
block.streaming,
)
) {
return undefined;
}
const messages = transcriptBlocksToDaemonMessages(blocks, {
includeSourceIdentity: true,
});
@@ -10487,103 +10487,6 @@ describe('DaemonSessionProvider', () => {
]);
});
it('publishes a failed retirement when an epoch reset destroys the terminal', async () => {
// A cold restore emits only `session_update` chunks, never terminal
// events. The admitted prompt's terminal is gone; the host must still hear
// a `failed` settlement instead of waiting forever.
const resyncGate = createDeferred<void>();
const reloaded = createDeferred<void>();
const firstSession = createMockSession({
sessionId: 'session-retire-epoch',
submitPrompt: vi.fn(async () => ({
promptId: 'prompt-1',
lastEventId: 9,
})),
events: async function* epochResetThenResync(
opts: { signal?: AbortSignal } = {},
) {
await Promise.race([
resyncGate.promise,
new Promise<void>((resolve) =>
opts.signal?.addEventListener('abort', () => resolve(), {
once: true,
}),
),
]);
if (opts.signal?.aborted) return;
yield {
id: 10,
v: 1,
type: 'state_resync_required',
data: { reason: 'epoch_reset' },
} satisfies DaemonEvent;
},
});
const reloadedSession = createMockSession({
sessionId: 'session-retire-epoch',
hasActivePrompt: false,
events: createPendingEvents(reloaded),
replaySnapshot: {
compactedReplay: [
{
id: 11,
v: 1,
type: 'session_update',
data: {
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'answer before the crash' },
},
},
},
],
liveJournal: [],
},
});
sdkMocks.sessions.push(firstSession, reloadedSession);
const settlements: DaemonPromptSettledEvent[] = [];
let actions: DaemonUiSessionActions | undefined;
function Harness() {
actions = useDaemonActions();
useDaemonPromptSettled((event) => {
settlements.push(event);
});
return null;
}
await renderWithProvider(<Harness />, {
autoConnect: true,
reconnectDelayMs: 1,
maxReconnectDelayMs: 1,
});
await act(async () => {
const prompt = requireActions(actions).sendPrompt('hello');
void prompt.catch(() => {});
await flushPromises();
});
expect(settlements).toEqual([]);
await act(async () => {
resyncGate.resolve();
await reloaded.promise;
await flushPromises();
});
expect(settlements).toEqual([
{
sessionId: 'session-retire-epoch',
promptId: 'prompt-1',
outcome: 'failed',
error: {
message: 'Prompt terminal lost across daemon epoch reset',
code: 'epoch_reset',
},
},
]);
});
it('publishes a replayed cancelled terminal after the prompt was cancelled', async () => {
// `cancel()` removes the ActivePrompt (deletes it in `finally`) but the
// admission key survives; a `turn_complete{stopReason:'cancelled'}` that
@@ -1092,14 +1092,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
const activePromptsRef = useRef<Map<string, ActivePrompt>>(new Map());
const settledPromptsRef = useRef<Map<string, SettledPrompt>>(new Map());
const locallyBoundPromptKeysRef = useRef(new Set<string>());
// A locally bound prompt whose terminal an epoch reset may have destroyed.
// Recorded when `requestEpochResetReload` discards the binding, consumed
// after the reload's replay injection to publish a `failed` retirement when
// the fresh snapshot carried no terminal for it (a cold restore emits only
// `session_update` chunks). Cleared on use so unrelated reloads stay silent.
const epochResetBoundPromptRef = useRef<
{ sessionId: string; promptId: string } | undefined
>(undefined);
const promptSettlementListenersRef = useRef<Set<DaemonPromptSettledListener>>(
new Set(),
);
@@ -1132,26 +1124,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
},
[],
);
// Publish a `failed` settlement for a still-bound prompt whose terminal was
// destroyed without ever being observed (session died, auth/terminal error,
// missing session). Stays inside the public outcome union and carries the
// same (sessionId, promptId) key so a late real terminal is deduped.
const retireAbandonedPrompt = useCallback(
(sessionId: string, code: string) => {
const active = activePromptsRef.current.get(sessionId);
if (!active?.promptId) return;
publishPromptSettlement({
sessionId,
promptId: active.promptId,
outcome: 'failed',
error: {
message: 'Prompt terminal lost before delivery',
code,
},
});
},
[publishPromptSettlement],
);
const pendingSessionLoadRef = useRef<PendingSessionLoad | undefined>(
undefined,
);
@@ -2890,33 +2862,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
publishPromptSettlement(replaySettlement);
}
}
// An epoch reset may have destroyed the terminal of a prompt this
// provider had bound: if the replay above did not settle it (the
// admission key is still present, so no terminal arrived), retire
// it as `failed` rather than leaving a host keyed on
// `onAssistantTurnSettled` waiting forever.
const epochResetBoundPrompt = epochResetBoundPromptRef.current;
epochResetBoundPromptRef.current = undefined;
if (
epochResetBoundPrompt &&
epochResetBoundPrompt.sessionId === activeSession.sessionId &&
locallyBoundPromptKeysRef.current.has(
getPromptSettledKey(
epochResetBoundPrompt.sessionId,
epochResetBoundPrompt.promptId,
),
)
) {
publishPromptSettlement({
sessionId: epochResetBoundPrompt.sessionId,
promptId: epochResetBoundPrompt.promptId,
outcome: 'failed',
error: {
message: 'Prompt terminal lost across daemon epoch reset',
code: 'epoch_reset',
},
});
}
setConnection((c) => ({ ...c, catchingUp: undefined }));
// Release the raw snapshot only after the injection above
// completed: if normalization/dispatch threw, the recovery path
@@ -3268,16 +3213,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
const active = activePromptsRef.current.get(
activeSession.sessionId,
);
if (active?.promptId) {
// The reset may have destroyed the turn's terminal (a cold
// restore emits only `session_update` chunks, never terminals).
// Remember the bound prompt so the reload can retire it as
// `failed` if the fresh snapshot carries no terminal for it.
epochResetBoundPromptRef.current = {
sessionId: activeSession.sessionId,
promptId: active.promptId,
};
}
active?.controller.abort();
activePromptsRef.current.delete(activeSession.sessionId);
if (restoredActivePrompt) {
@@ -3925,7 +3860,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
const isAuthFailure = isAuthFailureHttpError(error);
const isTerminal = isTerminalSessionHttpError(error);
if (failedSessionId && (isAuthFailure || isTerminal)) {
retireAbandonedPrompt(failedSessionId, 'session_error');
const active = activePromptsRef.current.get(failedSessionId);
active?.controller.abort();
activePromptsRef.current.delete(failedSessionId);
@@ -4357,7 +4291,6 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) {
errorStatus,
);
}
retireAbandonedPrompt(deadSessionId, 'session_missing');
const active = activePromptsRef.current.get(deadSessionId);
active?.controller.abort();
activePromptsRef.current.delete(deadSessionId);