From fac47ff741f3b841fb0d174025b6537c2cd91bc4 Mon Sep 17 00:00:00 2001 From: dreamWB <22347282+dreamWB@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:36:59 +0800 Subject: [PATCH 01/12] feat(web-shell): expose assistant turn settlement --- ...-28-web-shell-assistant-turn-settlement.md | 61 ++++ packages/web-shell/README.md | 26 ++ packages/web-shell/client/App.test.tsx | 160 +++++++++++ packages/web-shell/client/App.tsx | 45 ++- packages/web-shell/client/customization.tsx | 21 ++ packages/web-shell/client/index.tsx | 2 + packages/webui/src/daemon-react-sdk.ts | 6 + packages/webui/src/daemon/index.ts | 4 + .../session/DaemonSessionProvider.test.tsx | 264 ++++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 195 +++++++++++-- packages/webui/src/daemon/session/index.ts | 4 + packages/webui/src/daemon/session/types.ts | 29 ++ 12 files changed, 794 insertions(+), 23 deletions(-) create mode 100644 docs/design/2026-08-28-web-shell-assistant-turn-settlement.md diff --git a/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md new file mode 100644 index 00000000000..057a5b36be0 --- /dev/null +++ b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md @@ -0,0 +1,61 @@ +# Web Shell assistant turn settlement + +## Goal + +Expose a host callback for the semantic end of an assistant turn without requiring consumers to infer completion from prompt-status transitions or scan a changing transcript. + +The callback is a generic Web Shell lifecycle contract. It is not tied to any embedding product, artifact projection, or workspace side effect. + +## Contract + +`onAssistantTurnSettled` receives: + +- `sessionId` and daemon-assigned `promptId`; their tuple is the stable idempotency key; +- `outcome`: `completed`, `cancelled`, or `failed`; +- the daemon `stopReason` when a turn completed; +- `transcriptComplete`, which is false only when bounded live-journal repair could not restore the complete turn; +- the final visible assistant message when the turn produced one; cancelled and failed turns may omit it or carry partial content. + +The callback is optional. Existing `onSessionChange({ type: 'turn_complete' })` behavior remains unchanged. + +## Lifecycle and ordering + +The daemon prompt terminal (`turn_complete` or `turn_error`) is authoritative. Prompt-status `idle`, render completion, and history replay are not terminal signals. + +If a process or transport fails without delivering either terminal event, no settlement is published. This fail-closed behavior avoids reporting an unproven completion; connection health remains a separate lifecycle. + +The daemon session provider publishes a settlement only after it has: + +1. flushed buffered transcript deltas; +2. applied `assistant.done` and the terminal event's own transcript projection; +3. completed live-journal repair when a truncated active turn can be repaired. + +Ordinary session load, branch/split transcript replay, and older-history pagination never publish settlements. A terminal received while reconnecting an already active prompt may publish because it is a previously unseen live lifecycle transition, not history playback. + +Duplicate terminal delivery is suppressed for the lifetime of a mounted session provider using `(sessionId, promptId)`. Hosts must use the same key for durable idempotency across remounts. + +`prompt_cancelled` is a cancellation request, not a confirmed prompt terminal, and does not publish a settlement by itself. Waiting for permission or `ask_user_question` also does not publish. + +Listener failures are isolated from daemon stream processing. Artifact, tool-result persistence, and workspace projection keep their independent lifecycle. + +## Implementation boundary + +The WebUI daemon session layer owns authoritative terminal observation, transcript ordering, replay suppression, repair, and duplicate suppression. It exposes a subscription hook with prompt-level settlement metadata. + +Web Shell owns projection of the final visible assistant message and the public callback type. It does not add daemon routes or change daemon event payloads. + +## Tests + +- `TC-01`: normal terminal publishes once with the complete final message. +- `TC-02`: assistant/tool/assistant turn returns the final assistant message. +- `TC-03`: permission or `ask_user_question` waiting does not publish. +- `TC-04`: confirmed cancellation publishes `cancelled`; cancellation request alone does not. +- `TC-05`: `turn_error` publishes `failed` and preserves any partial assistant message. +- `TC-06`: reconnect catch-up for an active local prompt publishes once. +- `TC-07`: ordinary history replay and older-history pagination do not publish. +- `TC-08`: duplicate terminal events publish once for a mounted provider. +- `TC-09`: session switch cannot attribute an old terminal to the new session. +- `TC-10`: successful live-journal repair delays publication until the repaired transcript is committed; failed repair marks `transcriptComplete: false`. +- `TC-11`: listener exceptions do not interrupt subsequent daemon events or listeners. + +No visual UI changes are introduced, so browser screenshot validation is not applicable. Package unit tests, build, typecheck, and repository preflight are the delivery gates. diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index ef2912845e7..d23f5c62206 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -175,6 +175,32 @@ export function App() { > **注意**:不要在已有 `DaemonSessionProvider` 下使用 > `WebShellWithProviders`,否则会创建嵌套的重复 Provider。 +### Assistant Turn 终态回调 + +宿主需要在一轮 Assistant 回答真正结束后处理结果时,应使用 +`onAssistantTurnSettled`,不要从 `onStreamingStateChange('idle')` 推断: + +```tsx + { + const key = `${event.sessionId}:${event.promptId}`; + persistResultOnce(key, event.message?.content, event.outcome); + }} +/> +``` + +该回调来自 daemon 的 prompt 终态,并在终态 transcript 已提交后触发。普通历史加载、 +分支回放和向前分页不会触发;断线期间遗漏、重连后补收的活动 prompt 终态可以触发。 +`(sessionId, promptId)` 是跨挂载幂等键。单个 Provider 挂载期间重复终态会被抑制, +宿主仍应使用该键做持久化幂等。 + +`outcome` 为 `completed`、`cancelled` 或 `failed`。取消和失败可能没有 Assistant +消息,也可能携带终止前的部分内容。`transcriptComplete: false` 表示 Web Shell 已收到 +终态,但无法从受限的 live journal 中恢复完整 transcript。等待 permission 或 +`ask_user_question` 不属于终态。Artifact 和 workspace projection 有各自的生命周期, +不由该回调表示。进程硬崩溃且没有送达 prompt 终态时不会触发回调;连接失败应由连接 +状态单独处理,不能被当作已完成的 turn。 + ### 3. 只读 ChatRecord JSONL `WebShellTranscript` 只接收已经投影完成的 blocks,不连接 daemon,也不提供 composer、 diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index cf86804df39..4187eb48015 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -393,6 +393,17 @@ const { | { data: string; media_type: string }[] | undefined, streamingState: 'idle' as StreamingState, + promptSettledListener: undefined as + | ((event: { + sessionId: string; + promptId: string; + outcome: 'completed' | 'cancelled' | 'failed'; + stopReason?: string; + eventId?: number; + transcriptComplete: boolean; + error?: { message: string; code?: string }; + }) => void) + | undefined, sessionHasActivePrompt: false, blocks: [] as unknown[], liveBlocks: undefined as unknown[] | undefined, @@ -554,6 +565,11 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => { useActions: () => mockSessionActions, useConnection: () => mockConnection, useDaemonSessionOwnerGuard: () => ownerGuard, + useDaemonPromptSettled: ( + listener: typeof testState.promptSettledListener, + ) => { + testState.promptSettledListener = listener; + }, useDaemonFollowupSuggestion: () => ({ followupState: null, clear: mockFollowup.clear, @@ -5211,6 +5227,7 @@ beforeEach(() => { testState.inputAnnotations = undefined; testState.promptImages = undefined; testState.streamingState = 'idle'; + testState.promptSettledListener = undefined; testState.sessionHasActivePrompt = false; testState.blocks = []; testState.liveBlocks = undefined; @@ -12786,6 +12803,149 @@ describe('App session callbacks', () => { }); }); + it('exposes the final assistant message for an authoritative settled turn', async () => { + const base = { + promptId: 'prompt-1', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }; + testState.blocks = [ + { ...base, id: 'user-1', kind: 'user', text: 'question' }, + { + ...base, + id: 'assistant-before-tool', + kind: 'assistant', + text: 'I will check.', + streaming: false, + }, + { + ...base, + id: 'tool-1', + kind: 'tool', + toolCallId: 'call-1', + title: 'Lookup', + status: 'completed', + preview: { kind: 'generic' }, + }, + { + ...base, + id: 'assistant-final', + kind: 'assistant', + text: 'The final answer.', + streaming: false, + serverTimestamp: 42, + }, + ]; + const onAssistantTurnSettled = vi.fn(); + renderApp({ onAssistantTurnSettled }); + await flush(); + + act(() => { + testState.promptSettledListener?.({ + sessionId: 'session-1', + promptId: 'prompt-1', + outcome: 'completed', + stopReason: 'end_turn', + eventId: 10, + transcriptComplete: true, + }); + }); + + expect(onAssistantTurnSettled).toHaveBeenCalledWith({ + sessionId: 'session-1', + promptId: 'prompt-1', + outcome: 'completed', + stopReason: 'end_turn', + eventId: 10, + transcriptComplete: true, + message: { + id: 'assistant-final', + content: 'The final answer.', + isStreaming: false, + timestamp: 42, + }, + }); + }); + + it('does not project a previous session terminal onto the current transcript', async () => { + testState.blocks = [ + { + id: 'assistant-current', + kind: 'assistant', + promptId: 'shared-prompt-id', + text: 'Current session answer', + streaming: false, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ]; + const onAssistantTurnSettled = vi.fn(); + renderApp({ onAssistantTurnSettled }); + await flush(); + + act(() => { + testState.promptSettledListener?.({ + sessionId: 'previous-session', + promptId: 'shared-prompt-id', + outcome: 'completed', + stopReason: 'end_turn', + eventId: 10, + transcriptComplete: true, + }); + }); + + expect(onAssistantTurnSettled).toHaveBeenCalledWith({ + sessionId: 'previous-session', + promptId: 'shared-prompt-id', + outcome: 'completed', + stopReason: 'end_turn', + eventId: 10, + transcriptComplete: true, + }); + }); + + it('includes available partial assistant content for a failed turn', async () => { + testState.blocks = [ + { + id: 'assistant-partial', + kind: 'assistant', + promptId: 'prompt-failed', + text: 'Partial answer before failure', + streaming: false, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + ]; + const onAssistantTurnSettled = vi.fn(); + renderApp({ onAssistantTurnSettled }); + await flush(); + + act(() => { + testState.promptSettledListener?.({ + sessionId: 'session-1', + promptId: 'prompt-failed', + outcome: 'failed', + eventId: 11, + transcriptComplete: true, + error: { message: 'model unavailable', code: 'model_error' }, + }); + }); + + expect(onAssistantTurnSettled).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: 'failed', + message: expect.objectContaining({ + id: 'assistant-partial', + content: 'Partial answer before failure', + isStreaming: false, + }), + }), + ); + }); + it('gates direct submissions and dispatches compatible submit events', async () => { const onSubmitBefore = vi.fn().mockResolvedValue(undefined); const onSessionChange = vi.fn(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 016393ebd5c..6d5623c4dda 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -19,6 +19,7 @@ import { useActions, useConnection, useDaemonFollowupSuggestion, + useDaemonPromptSettled, useSettings, useProviders, useSessionNotices, @@ -65,7 +66,10 @@ import { WEB_SHELL_SIDE_TASK_SOURCE_TYPE, } from './constants/sessions'; import { extractPendingPermission } from './adapters/transcriptAdapter'; -import { isRetryableTurnErrorKind } from './adapters/transcriptToMessages'; +import { + isRetryableTurnErrorKind, + transcriptBlocksToDaemonMessages, +} from './adapters/transcriptToMessages'; import { MessageList, type MessageListHandle } from './components/MessageList'; import { SubagentDetailsProvider } from './subagentDetailsContext'; import { MonitorDetailsProvider } from './monitorDetailsContext'; @@ -346,6 +350,8 @@ import { type WebShellBottomStatusItem, type WebShellPreparedSubmit, type WebShellSubmitSnapshot, + type WebShellAssistantMessageInfo, + type WebShellAssistantTurnSettledEvent, } from './customization'; import type { CommandDisplayCategoryOrder } from './utils/commandDisplay'; import { WebShellPortalRootContext } from './portalRoot'; @@ -699,6 +705,26 @@ function getLatestUserBlock( return undefined; } +function getSettledAssistantMessage( + blocks: readonly DaemonTranscriptBlock[], + promptId: string, +): WebShellAssistantMessageInfo | undefined { + const messages = transcriptBlocksToDaemonMessages( + blocks.filter((block) => block.promptId === promptId), + ); + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role !== 'assistant') continue; + return { + id: message.id, + content: message.content, + isStreaming: message.isStreaming, + timestamp: message.timestamp, + }; + } + return undefined; +} + function matchesUserMessageIdentity( block: DaemonTranscriptBlock | undefined, identity: TranscriptUserMessageIdentity | undefined, @@ -1037,6 +1063,11 @@ export interface WebShellProps { onConnectionChange?: (status: string) => void; /** Called when prompt status changes (idle/waiting/responding). */ onStreamingStateChange?: (state: DaemonStreamingState) => void; + /** + * Called after an authoritative daemon prompt terminal has been committed to + * the transcript. Ordinary history replay does not emit this callback. + */ + onAssistantTurnSettled?: (event: WebShellAssistantTurnSettledEvent) => void; /** * Called with the initial merged agent task snapshot and when its roster, * status, or stable metadata changes. Poll-only runtime, stats, and activity @@ -1968,6 +1999,7 @@ export function App({ shadowDom, onConnectionChange, onStreamingStateChange, + onAssistantTurnSettled, onError, onBugReport, hiddenSlashCommands, @@ -2277,10 +2309,19 @@ export function App({ const CustomComposerHeader = renderComposerHeader; const CustomComposerFooter = renderComposerFooter; const store = useTranscriptStore(); + const connection = useConnection(); + useDaemonPromptSettled((event) => { + const callback = onAssistantTurnSettled; + if (!callback) return; + const message = + connection.sessionId === event.sessionId + ? getSettledAssistantMessage(store.getSnapshot().blocks, event.promptId) + : undefined; + callback({ ...event, ...(message ? { message } : {}) }); + }); const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot({ structuralOnly: true, }); - const connection = useConnection(); const logicalSessionKey = getLogicalSessionKey( connection.sessionId, connection.workspaceCwd, diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index 9577cc2048f..10a50ef47e8 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -219,6 +219,27 @@ export interface WebShellAssistantMessageInfo { timestamp?: number; } +export type WebShellAssistantTurnOutcome = 'completed' | 'cancelled' | 'failed'; + +export interface WebShellAssistantTurnSettledEvent { + sessionId: string; + /** Daemon-assigned prompt identifier and canonical turn identity. */ + promptId: string; + outcome: WebShellAssistantTurnOutcome; + /** Daemon terminal reason. Present for completed and cancelled turns. */ + stopReason?: string; + /** Terminal SSE cursor when available; not a replacement for the idempotency key. */ + eventId?: number; + /** False when bounded live-journal repair could not restore the full turn. */ + transcriptComplete: boolean; + /** Final visible assistant message, when the turn produced one. */ + message?: WebShellAssistantMessageInfo; + error?: { + message: string; + code?: string; + }; +} + export interface WebShellAssistantTurnFooterRenderInfo { /** User-message id for the head of the completed turn. */ turnId: string; diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 870b55ee438..340eca54438 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -216,6 +216,8 @@ export type { WebShellMarkdownChartCustomization, WebShellMarkdownCustomization, WebShellAssistantMessageInfo, + WebShellAssistantTurnOutcome, + WebShellAssistantTurnSettledEvent, WebShellAssistantTurnFooterRenderInfo, WebShellIconSource, WebShellTaskInfo, diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index 63734f57581..e5c3e495b63 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -160,6 +160,9 @@ export { useDaemonTranscriptStore as useTranscriptStore } from './daemon/session /** Low-level prompt lifecycle status (queued, streaming, idle). */ export { useDaemonPromptStatus as usePromptStatus } from './daemon/session/index.js'; +/** Subscribe to authoritative prompt terminal events after transcript commit. */ +export { useDaemonPromptSettled } from './daemon/session/index.js'; + /** Server-pushed prompt follow-up suggestions for daemon-backed UIs. */ export { useDaemonFollowupSuggestion } from './daemon/index.js'; @@ -218,6 +221,9 @@ export type { DaemonStreamingState, /** Prompt submission status: `'idle' | 'waiting' | 'streaming'`. */ DaemonPromptStatus, + DaemonPromptSettledEvent, + DaemonPromptSettledListener, + DaemonPromptSettlementOutcome, DaemonReasoningControls, /** Hook return value for daemon follow-up suggestions. */ UseDaemonFollowupSuggestionReturn, diff --git a/packages/webui/src/daemon/index.ts b/packages/webui/src/daemon/index.ts index c1600c74f67..a63d8753d5f 100644 --- a/packages/webui/src/daemon/index.ts +++ b/packages/webui/src/daemon/index.ts @@ -11,6 +11,7 @@ export { useDaemonActiveTodoList, useDaemonConnection, useDaemonPendingPermissions, + useDaemonPromptSettled, useDaemonPromptStatus, useDaemonSessionNotices, useDaemonSessionOwnerGuard, @@ -45,6 +46,9 @@ export type { DaemonNoticeSeverity, DaemonPromptFile, DaemonPromptImage, + DaemonPromptSettledEvent, + DaemonPromptSettledListener, + DaemonPromptSettlementOutcome, DaemonPromptStatus, DaemonReasoningControls, DaemonSessionActions, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 572c9b4e536..ca0b8f99350 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -31,6 +31,7 @@ import { useDaemonConnection, useDaemonSessionNotices, useDaemonPendingPermissions, + useDaemonPromptSettled, useDaemonPromptStatus, useDaemonStreamingState, useDaemonTranscriptBlocks, @@ -42,6 +43,7 @@ import { type DaemonConnectionState, type DaemonSessionActions, type DaemonSessionNotice, + type DaemonPromptSettledEvent, type DaemonWorkspaceEventSignals, } from './DaemonSessionProvider.js'; import { @@ -5771,6 +5773,7 @@ describe('DaemonSessionProvider', () => { let signals: DaemonWorkspaceEventSignals | undefined; let connection: DaemonConnectionState | undefined; let actions: DaemonSessionActions | undefined; + const settlements: DaemonPromptSettledEvent[] = []; function Harness() { blocks = useDaemonTranscriptBlocks(); @@ -5778,6 +5781,7 @@ describe('DaemonSessionProvider', () => { signals = useDaemonWorkspaceEventSignals(); connection = useDaemonConnection(); actions = useDaemonActions(); + useDaemonPromptSettled((event) => settlements.push(event)); return null; } @@ -5811,6 +5815,7 @@ describe('DaemonSessionProvider', () => { await flushPromises(); }); expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(1); + expect(settlements).toEqual([]); let localPrompt: Promise | undefined; if (_label === 'complete') { await act(async () => { @@ -5895,6 +5900,19 @@ describe('DaemonSessionProvider', () => { expect( sdkMocks.MockDaemonSessionClient.load.mock.calls[1]?.[2], ).not.toHaveProperty('historyPageSize'); + expect(settlements).toEqual([ + expect.objectContaining({ + sessionId: 'session-live-repair', + promptId: 'prompt-live', + outcome: + _label === 'cancelled' + ? 'cancelled' + : _label === 'error' + ? 'failed' + : 'completed', + transcriptComplete: true, + }), + ]); expect(initialSession.prompt).not.toHaveBeenCalled(); expect(repairedSession.prompt).not.toHaveBeenCalled(); const midTurnInjected = getSidechannelMidTurnInjected(); @@ -6323,11 +6341,13 @@ describe('DaemonSessionProvider', () => { let blocks: readonly DaemonTranscriptBlock[] = []; let notices: readonly DaemonSessionNotice[] = []; let connection: DaemonConnectionState | undefined; + const settlements: DaemonPromptSettledEvent[] = []; function Harness() { blocks = useDaemonTranscriptBlocks(); notices = useDaemonSessionNotices().notices; connection = useDaemonConnection(); + useDaemonPromptSettled((event) => settlements.push(event)); return null; } @@ -6378,6 +6398,14 @@ describe('DaemonSessionProvider', () => { (notice) => notice.code === 'daemon.live_journal_repair.failed', ), ).toHaveLength(1); + expect(settlements).toEqual([ + expect.objectContaining({ + sessionId: 'session-live-invalid-repair', + promptId: 'prompt-live', + outcome: 'completed', + transcriptComplete: false, + }), + ]); if ( invalidCase === 'network error' || invalidCase === 'auth error' || @@ -11487,10 +11515,12 @@ describe('DaemonSessionProvider', () => { let actions: DaemonUiSessionActions | undefined; let streamingState: ReturnType = 'idle'; let blocks: readonly DaemonTranscriptBlock[] = []; + const settlements: DaemonPromptSettledEvent[] = []; function Harness() { actions = useDaemonActions(); streamingState = useDaemonStreamingState(); blocks = useDaemonTranscriptBlocks(); + useDaemonPromptSettled((event) => settlements.push(event)); return null; } @@ -11531,6 +11561,16 @@ describe('DaemonSessionProvider', () => { }), ]), ); + expect(settlements).toEqual([ + { + sessionId: 'session-ring-active-prompt', + promptId: 'prompt-1', + outcome: 'completed', + stopReason: 'end_turn', + eventId: 13, + transcriptComplete: true, + }, + ]); }); it('rejects active prompts from replay turn_error after ring eviction', async () => { @@ -15086,6 +15126,230 @@ describe('DaemonSessionProvider', () => { ); }); + it('publishes authoritative prompt settlements once after terminal transcript commit', async () => { + const delivered = createDeferred(); + const listener = vi.fn((_event: DaemonPromptSettledEvent) => + delivered.resolve(), + ); + const terminal: DaemonEvent = { + id: 4, + v: 1, + type: 'turn_complete', + promptId: 'prompt-live', + data: { promptId: 'prompt-live', stopReason: 'end_turn' }, + }; + sdkMocks.sessions.push( + createMockSession({ + events: async function* settlementEvents() { + yield { + id: 1, + v: 1, + type: 'session_update', + promptId: 'prompt-live', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'before tool' }, + }, + }, + } satisfies DaemonEvent; + yield { + id: 2, + v: 1, + type: 'session_update', + promptId: 'prompt-live', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'final answer' }, + }, + }, + } satisfies DaemonEvent; + yield terminal; + yield terminal; + }, + }), + ); + let store: DaemonTranscriptStore | undefined; + + function Harness() { + store = useDaemonTranscriptStore(); + useDaemonPromptSettled(listener); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await delivered.promise; + await flushPromises(); + }); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener).toHaveBeenCalledWith({ + sessionId: 'session-1', + promptId: 'prompt-live', + outcome: 'completed', + stopReason: 'end_turn', + eventId: 4, + transcriptComplete: true, + }); + expect(store?.getSnapshot().blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'assistant', + text: 'before toolfinal answer', + streaming: false, + promptId: 'prompt-live', + }), + ]), + ); + }); + + it('isolates prompt settlement listener failures', async () => { + const delivered = createDeferred(); + const failingListener = vi.fn(() => { + throw new Error('host callback failed'); + }); + const healthyListener = vi.fn(() => delivered.resolve()); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + sdkMocks.sessions.push( + createMockSession({ + events: async function* terminalEvent() { + yield { + id: 1, + v: 1, + type: 'turn_complete', + promptId: 'prompt-live', + data: { promptId: 'prompt-live', stopReason: 'end_turn' }, + } satisfies DaemonEvent; + }, + }), + ); + + function Harness() { + useDaemonPromptSettled(failingListener); + useDaemonPromptSettled(healthyListener); + return null; + } + + try { + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await delivered.promise; + await flushPromises(); + }); + + expect(failingListener).toHaveBeenCalledOnce(); + expect(healthyListener).toHaveBeenCalledOnce(); + expect(consoleError).toHaveBeenCalledWith( + '[DaemonSessionProvider] prompt settlement listener failed', + expect.objectContaining({ message: 'host callback failed' }), + ); + } finally { + consoleError.mockRestore(); + } + }); + + it('publishes failed and confirmed cancelled settlements but not waits, cancellation requests, or history replay', async () => { + const terminalsDelivered = createDeferred(); + const listener = vi.fn((event: DaemonPromptSettledEvent) => { + if (event.outcome === 'cancelled') terminalsDelivered.resolve(); + }); + sdkMocks.sessions.push( + createMockSession({ + replaySnapshot: { + compactedReplay: [ + { + id: 1, + v: 1, + type: 'turn_complete', + promptId: 'prompt-history', + data: { promptId: 'prompt-history', stopReason: 'end_turn' }, + }, + ], + liveJournal: [], + }, + events: async function* failureEvents() { + yield { + id: 2, + v: 1, + type: 'permission_request', + promptId: 'prompt-waiting', + data: { + requestId: 'permission-1', + sessionId: 'session-1', + title: 'Ask user 1 question', + toolCall: { + toolCallId: 'tool-1', + rawInput: { questions: [] }, + }, + options: [], + }, + } satisfies DaemonEvent; + yield { + id: 3, + v: 1, + type: 'prompt_cancelled', + data: { sessionId: 'session-1', reason: 'user_cancel' }, + } satisfies DaemonEvent; + yield { + id: 4, + v: 1, + type: 'turn_error', + promptId: 'prompt-failed', + data: { + promptId: 'prompt-failed', + message: 'model unavailable', + code: 'model_error', + }, + } satisfies DaemonEvent; + yield { + id: 5, + v: 1, + type: 'turn_complete', + promptId: 'prompt-cancelled', + data: { + promptId: 'prompt-cancelled', + stopReason: 'cancelled', + }, + } satisfies DaemonEvent; + }, + }), + ); + + function Harness() { + useDaemonPromptSettled(listener); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await terminalsDelivered.promise; + await flushPromises(); + }); + + expect(listener.mock.calls.map(([event]) => event)).toEqual([ + { + sessionId: 'session-1', + promptId: 'prompt-failed', + outcome: 'failed', + eventId: 4, + transcriptComplete: true, + error: { message: 'model unavailable', code: 'model_error' }, + }, + { + sessionId: 'session-1', + promptId: 'prompt-cancelled', + outcome: 'cancelled', + stopReason: 'cancelled', + eventId: 5, + transcriptComplete: true, + }, + ]); + }); + async function renderWithProvider( children: ReactNode, props: Partial = {}, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index b9debc05224..7af05c7503d 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -108,6 +108,9 @@ import type { AddDaemonSessionNotice, DaemonConnectionState, DaemonPromptStatus, + DaemonPromptSettledEvent, + DaemonPromptSettledListener, + DaemonPromptSettlementSource, DaemonSessionActions, DaemonSessionContextValue, DaemonSessionNotice, @@ -127,6 +130,9 @@ export type { DaemonNoticeOperation, DaemonNoticeSeverity, DaemonPromptImage, + DaemonPromptSettledEvent, + DaemonPromptSettledListener, + DaemonPromptSettlementOutcome, DaemonPromptStatus, DaemonSessionActions, DaemonSessionContextValue, @@ -158,6 +164,7 @@ interface LiveJournalRepairEpisode { lastObservedEventId: number; terminalSeen: boolean; attempted: boolean; + pendingSettlement?: DaemonPromptSettledEvent; controller?: AbortController; } @@ -233,6 +240,44 @@ function assistantDoneFromTurnEvent( }; } +function promptSettledFromTurnEvent( + sessionId: string, + event: DaemonEvent, + transcriptComplete = true, +): DaemonPromptSettledEvent | undefined { + if (event.type !== 'turn_complete' && event.type !== 'turn_error') { + return undefined; + } + const promptId = eventPromptId(event); + if (!promptId) return undefined; + const data = isRecord(event.data) ? event.data : undefined; + if (event.type === 'turn_complete') { + const stopReason = + typeof data?.['stopReason'] === 'string' + ? data['stopReason'] + : 'end_turn'; + return { + sessionId, + promptId, + outcome: stopReason === 'cancelled' ? 'cancelled' : 'completed', + stopReason, + ...(event.id !== undefined ? { eventId: event.id } : {}), + transcriptComplete, + }; + } + const message = + typeof data?.['message'] === 'string' ? data['message'] : 'Turn failed'; + const code = typeof data?.['code'] === 'string' ? data['code'] : undefined; + return { + sessionId, + promptId, + outcome: 'failed', + ...(event.id !== undefined ? { eventId: event.id } : {}), + transcriptComplete, + error: { message, ...(code ? { code } : {}) }, + }; +} + function getPersistedReplayRecordId(event: DaemonEvent): string | undefined { // A `history_truncated` marker may carry a `recordId` anchor stamped by // the daemon's compaction engine — the last recordId it saw before the @@ -620,6 +665,9 @@ const DaemonTranscriptHistoryContext = createContext< const DaemonPromptStatusContext = createContext( undefined, ); +const DaemonPromptSettlementContext = createContext< + DaemonPromptSettlementSource | undefined +>(undefined); interface SessionNoticesValue { notices: readonly DaemonSessionNotice[]; dismissNotice(id: string): void; @@ -952,6 +1000,42 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const liveJournalRepairRef = useRef( undefined, ); + const promptSettlementListenersRef = useRef>( + new Set(), + ); + const publishedPromptSettlementsRef = useRef>(new Set()); + const promptSettlementSource = useMemo( + () => ({ + subscribe(listener) { + promptSettlementListenersRef.current.add(listener); + return () => promptSettlementListenersRef.current.delete(listener); + }, + }), + [], + ); + const publishPromptSettlement = useCallback( + (event: DaemonPromptSettledEvent) => { + const key = getPromptSettledKey(event.sessionId, event.promptId); + if (publishedPromptSettlementsRef.current.has(key)) return; + publishedPromptSettlementsRef.current.add(key); + const listeners = [...promptSettlementListenersRef.current]; + if (listeners.length === 0) return; + queueMicrotask(() => { + for (const listener of listeners) { + if (!promptSettlementListenersRef.current.has(listener)) continue; + try { + listener(event); + } catch (error) { + console.error( + '[DaemonSessionProvider] prompt settlement listener failed', + error, + ); + } + } + }); + }, + [], + ); const repairReloadRef = useRef< DaemonSessionActions['reloadSession'] | undefined >(undefined); @@ -1217,6 +1301,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { error instanceof Error ? error.message : String(error), recoverable: true, }); + if (repair.pendingSettlement) { + publishPromptSettlement({ + ...repair.pendingSettlement, + transcriptComplete: false, + }); + } liveJournalRepairRef.current = undefined; }, ); @@ -2127,6 +2217,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }; } else if (repairingEpisode) { liveJournalRepairRef.current = undefined; + if (repairingEpisode.pendingSettlement) { + publishPromptSettlement({ + ...repairingEpisode.pendingSettlement, + transcriptComplete: true, + }); + } } } else if (allUiEvents.length > 0) { store.dispatch(allUiEvents); @@ -2190,7 +2286,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }); } for (const replayEvent of replayEvents) { - settleActivePromptFromTurnEvent( + const activePromptSettled = settleActivePromptFromTurnEvent( activePromptsRef.current, settledPromptsRef.current, activeSession.sessionId, @@ -2200,6 +2296,22 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { passiveAssistantDoneTimerRef, { requireBoundPromptId: true }, ); + if (!activePromptSettled) continue; + const settlement = promptSettledFromTurnEvent( + activeSession.sessionId, + replayEvent, + ); + if (!settlement) continue; + const repair = liveJournalRepairRef.current; + if ( + repair?.sessionId === activeSession.sessionId && + repair.target.promptId === settlement.promptId + ) { + repair.pendingSettlement = settlement; + repair.terminalSeen = true; + } else { + publishPromptSettlement(settlement); + } } setConnection((c) => ({ ...c, catchingUp: undefined })); // Release the raw snapshot only after the injection above @@ -2819,7 +2931,21 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { () => setPromptStatus('idle'), ); } + if ( + event.type === 'turn_complete' || + event.type === 'turn_error' + ) { + // `turn_error` itself projects an error block after + // assistant.done. Commit that terminal projection before + // exposing settlement to subscribers. + flushTranscriptSync(); + } + const settlement = promptSettledFromTurnEvent( + activeSession.sessionId, + event, + ); const pendingRepair = liveJournalRepairRef.current; + let settlementDelayedForRepair = false; if ( pendingRepair?.sessionId === activeSession.sessionId && (event.type === 'turn_complete' || @@ -2827,10 +2953,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { eventPromptId(event) === pendingRepair.target.promptId ) { pendingRepair.terminalSeen = true; + if (settlement) { + pendingRepair.pendingSettlement = settlement; + settlementDelayedForRepair = true; + } queueMicrotask(tryLiveJournalRepair); } else if (pendingRepair?.terminalSeen) { queueMicrotask(tryLiveJournalRepair); } + if (settlement && !settlementDelayedForRepair) { + publishPromptSettlement(settlement); + } // ── state_resync_required handling ────────────────────── // Resyncs are transcript recovery signals, not prompt terminal // signals. For epoch_reset and ring_evicted we reload the session @@ -3339,6 +3472,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { clearNotices, addNotice, dismissNotice, + publishPromptSettlement, setConnectionSynchronous, ]); @@ -3942,27 +4076,29 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { return ( - - - - - - - + + + + + + - {children} - - - - - - - + + {children} + + + + + + + + ); } @@ -4338,6 +4474,23 @@ export function useDaemonPromptStatus(): DaemonPromptStatus { return promptStatus; } +export function useDaemonPromptSettled( + listener: DaemonPromptSettledListener | undefined, +): void { + const source = useContext(DaemonPromptSettlementContext); + if (!source) { + throw new Error( + 'useDaemonPromptSettled must be used within DaemonSessionProvider', + ); + } + const listenerRef = useRef(listener); + listenerRef.current = listener; + useEffect( + () => source.subscribe((event) => listenerRef.current?.(event)), + [source], + ); +} + export function useDaemonConnection(): DaemonConnectionState { const connection = useContext(DaemonConnectionContext); if (!connection) { diff --git a/packages/webui/src/daemon/session/index.ts b/packages/webui/src/daemon/session/index.ts index 3360bf3a5df..aac191d55c1 100644 --- a/packages/webui/src/daemon/session/index.ts +++ b/packages/webui/src/daemon/session/index.ts @@ -14,6 +14,7 @@ export { useDaemonActiveTodoList, useDaemonConnection, useDaemonPendingPermissions, + useDaemonPromptSettled, useDaemonPromptStatus, useDaemonSessionNotices, useDaemonStreamingState, @@ -34,6 +35,9 @@ export type { DaemonNoticeSeverity, DaemonPromptFile, DaemonPromptImage, + DaemonPromptSettledEvent, + DaemonPromptSettledListener, + DaemonPromptSettlementOutcome, DaemonPromptStatus, DaemonReasoningControls, DaemonSessionActions, diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 5dbe4f73f90..0f6b386202e 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -196,6 +196,35 @@ export interface DaemonSessionProviderProps { export type DaemonPromptStatus = 'idle' | 'waiting' | 'streaming'; +export type DaemonPromptSettlementOutcome = + | 'completed' + | 'cancelled' + | 'failed'; + +export interface DaemonPromptSettledEvent { + sessionId: string; + promptId: string; + outcome: DaemonPromptSettlementOutcome; + /** Daemon terminal reason. Present for `turn_complete` settlements. */ + stopReason?: string; + /** Terminal SSE cursor when available; not a replacement for the idempotency key. */ + eventId?: number; + /** False when live-journal repair could not restore the complete turn. */ + transcriptComplete: boolean; + error?: { + message: string; + code?: string; + }; +} + +export type DaemonPromptSettledListener = ( + event: DaemonPromptSettledEvent, +) => void; + +export interface DaemonPromptSettlementSource { + subscribe(listener: DaemonPromptSettledListener): () => void; +} + export type DaemonNoticeSeverity = 'info' | 'warning' | 'error'; export type DaemonNoticeCategory = From e4e488064209e3b4077327f809a76212a4fdb7c0 Mon Sep 17 00:00:00 2001 From: dreamWB <22347282+dreamWB@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:39:38 +0800 Subject: [PATCH 02/12] docs(web-shell): clarify settlement message availability --- docs/design/2026-08-28-web-shell-assistant-turn-settlement.md | 2 +- packages/web-shell/client/customization.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md index 057a5b36be0..ef2a8b0b174 100644 --- a/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md +++ b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md @@ -14,7 +14,7 @@ The callback is a generic Web Shell lifecycle contract. It is not tied to any em - `outcome`: `completed`, `cancelled`, or `failed`; - the daemon `stopReason` when a turn completed; - `transcriptComplete`, which is false only when bounded live-journal repair could not restore the complete turn; -- the final visible assistant message when the turn produced one; cancelled and failed turns may omit it or carry partial content. +- the final visible assistant message when it remains available in the committed current-session transcript; turns without assistant content and events delivered across a session switch omit it, while cancelled and failed turns may carry partial content. The callback is optional. Existing `onSessionChange({ type: 'turn_complete' })` behavior remains unchanged. diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index 10a50ef47e8..eba2b3d4e0b 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -232,7 +232,7 @@ export interface WebShellAssistantTurnSettledEvent { eventId?: number; /** False when bounded live-journal repair could not restore the full turn. */ transcriptComplete: boolean; - /** Final visible assistant message, when the turn produced one. */ + /** Final visible assistant message when still available in the current transcript. */ message?: WebShellAssistantMessageInfo; error?: { message: string; From 5bcf7412a6ff3737d06e6f6acac41438c96924aa Mon Sep 17 00:00:00 2001 From: dreamWB <22347282+dreamWB@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:07:45 +0800 Subject: [PATCH 03/12] fix(web-shell): harden turn settlement delivery --- ...-28-web-shell-assistant-turn-settlement.md | 7 +- packages/web-shell/README.md | 3 +- packages/web-shell/client/App.test.tsx | 60 +- packages/web-shell/client/App.tsx | 29 +- .../session/DaemonSessionProvider.test.tsx | 565 ++++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 92 +-- 6 files changed, 702 insertions(+), 54 deletions(-) diff --git a/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md index ef2a8b0b174..f5524651bc2 100644 --- a/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md +++ b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md @@ -13,7 +13,7 @@ The callback is a generic Web Shell lifecycle contract. It is not tied to any em - `sessionId` and daemon-assigned `promptId`; their tuple is the stable idempotency key; - `outcome`: `completed`, `cancelled`, or `failed`; - the daemon `stopReason` when a turn completed; -- `transcriptComplete`, which is false only when bounded live-journal repair could not restore the complete turn; +- `transcriptComplete`, which is false when replay integrity is degraded or bounded live-journal repair could not restore the complete turn before failing or being discarded; - the final visible assistant message when it remains available in the committed current-session transcript; turns without assistant content and events delivered across a session switch omit it, while cancelled and failed turns may carry partial content. The callback is optional. Existing `onSessionChange({ type: 'turn_complete' })` behavior remains unchanged. @@ -28,7 +28,7 @@ The daemon session provider publishes a settlement only after it has: 1. flushed buffered transcript deltas; 2. applied `assistant.done` and the terminal event's own transcript projection; -3. completed live-journal repair when a truncated active turn can be repaired. +3. completed live-journal repair when a truncated active turn can be repaired, or classified the retained transcript as incomplete before discarding an unsuccessful repair. Ordinary session load, branch/split transcript replay, and older-history pagination never publish settlements. A terminal received while reconnecting an already active prompt may publish because it is a previously unseen live lifecycle transition, not history playback. @@ -57,5 +57,8 @@ Web Shell owns projection of the final visible assistant message and the public - `TC-09`: session switch cannot attribute an old terminal to the new session. - `TC-10`: successful live-journal repair delays publication until the repaired transcript is committed; failed repair marks `transcriptComplete: false`. - `TC-11`: listener exceptions do not interrupt subsequent daemon events or listeners. +- `TC-12`: clearing or replacing a repair episode releases its held settlement once with `transcriptComplete: false`. +- `TC-13`: degraded catch-up replay and unrecoverable live-journal markers publish with `transcriptComplete: false`. +- `TC-14`: a terminal after reconnect consumes the restored-active snapshot and allows live or catch-up repair to finish. No visual UI changes are introduced, so browser screenshot validation is not applicable. Package unit tests, build, typecheck, and repository preflight are the delivery gates. diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index d23f5c62206..6f1c0584865 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -196,7 +196,8 @@ export function App() { `outcome` 为 `completed`、`cancelled` 或 `failed`。取消和失败可能没有 Assistant 消息,也可能携带终止前的部分内容。`transcriptComplete: false` 表示 Web Shell 已收到 -终态,但无法从受限的 live journal 中恢复完整 transcript。等待 permission 或 +终态,但 replay 已被标记为不完整,或受限 live journal 的修复在失败、会话切换或 +repair episode 被替换前未能恢复完整 transcript。等待 permission 或 `ask_user_question` 不属于终态。Artifact 和 workspace projection 有各自的生命周期, 不由该回调表示。进程硬崩溃且没有送达 prompt 终态时不会触发回调;连接失败应由连接 状态单独处理,不能被当作已完成的 turn。 diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 4187eb48015..34a0284d63a 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -12820,13 +12820,15 @@ describe('App session callbacks', () => { streaming: false, }, { - ...base, id: 'tool-1', kind: 'tool', toolCallId: 'call-1', title: 'Lookup', status: 'completed', preview: { kind: 'generic' }, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, }, { ...base, @@ -12836,6 +12838,15 @@ describe('App session callbacks', () => { streaming: false, serverTimestamp: 42, }, + { + ...base, + id: 'assistant-subagent', + kind: 'assistant', + parentToolCallId: 'call-1', + text: 'Subagent internal answer', + streaming: false, + serverTimestamp: 43, + }, ]; const onAssistantTurnSettled = vi.fn(); renderApp({ onAssistantTurnSettled }); @@ -12946,6 +12957,53 @@ describe('App session callbacks', () => { ); }); + it('omits the message when the settled prompt has no assistant content', async () => { + testState.blocks = [ + { + id: 'assistant-previous', + kind: 'assistant', + promptId: 'prompt-previous', + text: 'Previous answer', + streaming: false, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + { + id: 'user-failed', + kind: 'user', + promptId: 'prompt-failed-before-content', + text: 'Question that failed immediately', + clientReceivedAt: 2, + createdAt: 2, + updatedAt: 2, + }, + ]; + const onAssistantTurnSettled = vi.fn(); + renderApp({ onAssistantTurnSettled }); + await flush(); + + act(() => { + testState.promptSettledListener?.({ + sessionId: 'session-1', + promptId: 'prompt-failed-before-content', + outcome: 'failed', + eventId: 12, + transcriptComplete: true, + error: { message: 'model unavailable', code: 'model_error' }, + }); + }); + + expect(onAssistantTurnSettled).toHaveBeenCalledWith({ + sessionId: 'session-1', + promptId: 'prompt-failed-before-content', + outcome: 'failed', + eventId: 12, + transcriptComplete: true, + error: { message: 'model unavailable', code: 'model_error' }, + }); + }); + it('gates direct submissions and dispatches compatible submit events', async () => { const onSubmitBefore = vi.fn().mockResolvedValue(undefined); const onSessionChange = vi.fn(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 6d5623c4dda..e3da4e22626 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -66,10 +66,7 @@ import { WEB_SHELL_SIDE_TASK_SOURCE_TYPE, } from './constants/sessions'; import { extractPendingPermission } from './adapters/transcriptAdapter'; -import { - isRetryableTurnErrorKind, - transcriptBlocksToDaemonMessages, -} from './adapters/transcriptToMessages'; +import { isRetryableTurnErrorKind } from './adapters/transcriptToMessages'; import { MessageList, type MessageListHandle } from './components/MessageList'; import { SubagentDetailsProvider } from './subagentDetailsContext'; import { MonitorDetailsProvider } from './monitorDetailsContext'; @@ -709,17 +706,21 @@ function getSettledAssistantMessage( blocks: readonly DaemonTranscriptBlock[], promptId: string, ): WebShellAssistantMessageInfo | undefined { - const messages = transcriptBlocksToDaemonMessages( - blocks.filter((block) => block.promptId === promptId), - ); - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message?.role !== 'assistant') continue; + for (let index = blocks.length - 1; index >= 0; index -= 1) { + const block = blocks[index]; + if ( + block?.kind !== 'assistant' || + block.promptId !== promptId || + block.parentToolCallId !== undefined || + block.text.trim().length === 0 + ) { + continue; + } return { - id: message.id, - content: message.content, - isStreaming: message.isStreaming, - timestamp: message.timestamp, + id: block.id, + content: block.text, + isStreaming: block.streaming ?? false, + timestamp: block.serverTimestamp ?? block.clientReceivedAt, }; } return undefined; diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index ca0b8f99350..747d569dd44 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -5934,6 +5934,112 @@ describe('DaemonSessionProvider', () => { }, ); + it('publishes an incomplete settlement before clearing an in-flight repair', async () => { + const terminalGate = createDeferred(); + const repairLoad = createDeferred(); + const initialSession = createMockSession({ + sessionId: 'session-live-repair-clear', + hasActivePrompt: true, + lastEventId: 9, + replaySnapshot: { + compactedReplay: [], + liveJournal: [ + { + id: 8, + v: 1, + type: 'history_truncated', + promptId: 'prompt-live', + data: { + reason: 'replay_window_exceeded', + scope: 'live_journal', + truncatedEvents: 8, + retainedEvents: 1, + maxBytes: 1024, + maxEvents: 1, + fullTranscriptAvailable: true, + }, + }, + { + id: 9, + v: 1, + type: 'session_update', + promptId: 'prompt-live', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'partial tail' }, + }, + }, + }, + ], + }, + events: async function* terminalEvents( + options: { signal?: AbortSignal } = {}, + ) { + await terminalGate.promise; + if (options.signal?.aborted) return; + yield { + id: 10, + v: 1, + type: 'turn_complete', + promptId: 'prompt-live', + data: { promptId: 'prompt-live', stopReason: 'end_turn' }, + } satisfies DaemonEvent; + await new Promise((resolve) => + options.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + }, + }); + const repairedSession = createMockSession({ + sessionId: initialSession.sessionId, + }); + sdkMocks.sessions.push(initialSession); + + let actions: DaemonSessionActions | undefined; + const settlements: DaemonPromptSettledEvent[] = []; + function Harness() { + actions = useDaemonActions(); + useDaemonPromptSettled((event) => settlements.push(event)); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async (client: unknown): Promise => { + const repaired = await repairLoad.promise; + repaired.client = client as MockClient; + return repaired; + }, + ); + await act(async () => { + terminalGate.resolve(); + await flushPromises(); + }); + expect(settlements).toEqual([]); + + let clearPromise: Promise | undefined; + await act(async () => { + clearPromise = requireActions(actions).clearSession(); + await flushPromises(); + }); + expect(settlements).toEqual([ + expect.objectContaining({ + sessionId: initialSession.sessionId, + promptId: 'prompt-live', + outcome: 'completed', + transcriptComplete: false, + }), + ]); + + await act(async () => { + repairLoad.resolve(repairedSession); + await clearPromise; + await flushPromises(); + }); + }); + it('does not repair a live marker for a non-matching queued terminal', async () => { const terminalGate = createDeferred(); const session = createMockSession({ @@ -11573,6 +11679,465 @@ describe('DaemonSessionProvider', () => { ]); }); + it.each([ + { + label: 'degraded', + replayDegraded: true, + liveJournal: [] as DaemonEvent[], + }, + { + label: 'marked unrecoverable', + replayDegraded: false, + liveJournal: [ + { + id: 14, + v: 1, + type: 'history_truncated', + promptId: 'prompt-1', + data: { + reason: 'replay_window_exceeded', + scope: 'live_journal', + truncatedEvents: 8, + retainedEvents: 1, + maxBytes: 1024, + maxEvents: 1, + fullTranscriptAvailable: false, + }, + }, + ] as DaemonEvent[], + }, + ])( + 'marks catch-up settlement incomplete when the replay is $label', + async ({ replayDegraded, liveJournal }) => { + const ringEvicted = createDeferred(); + const reloaded = createDeferred(); + const firstSession = createMockSession({ + sessionId: 'session-ring-degraded', + lastEventId: 10, + submitPrompt: vi.fn(async () => ({ + promptId: 'prompt-1', + lastEventId: 10, + })), + events: async function* ringEvictedEvents() { + await ringEvicted.promise; + yield { + v: 1, + type: 'state_resync_required', + data: { + reason: 'ring_evicted', + lastDeliveredId: 10, + earliestAvailableId: 12, + }, + }; + }, + }); + const reloadedSession = createMockSession({ + sessionId: firstSession.sessionId, + replayDegraded, + replaySnapshot: { + compactedReplay: [ + { + id: 12, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'partial replay' }, + }, + }, + }, + { + id: 13, + v: 1, + type: 'turn_complete', + promptId: 'prompt-1', + data: { promptId: 'prompt-1', stopReason: 'end_turn' }, + }, + ], + liveJournal, + }, + events: createPendingEvents(reloaded), + }); + sdkMocks.sessions.push(firstSession, reloadedSession); + + let actions: DaemonUiSessionActions | undefined; + const settlements: DaemonPromptSettledEvent[] = []; + function Harness() { + actions = useDaemonActions(); + useDaemonPromptSettled((event) => settlements.push(event)); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + const promptResult = requireActions(actions).sendPrompt('ring prompt'); + await act(async () => flushPromises()); + await act(async () => { + ringEvicted.resolve(); + await reloaded.promise; + await flushPromises(); + }); + await act(async () => { + await expect(promptResult).resolves.toEqual({ stopReason: 'end_turn' }); + }); + + expect(settlements).toEqual([ + expect.objectContaining({ + sessionId: firstSession.sessionId, + promptId: 'prompt-1', + outcome: 'completed', + transcriptComplete: false, + }), + ]); + }, + ); + + it('repairs and publishes a live terminal after a reconnect restores an active prompt', async () => { + const ringEvicted = createDeferred(); + const reloadedStreamStarted = createDeferred(); + const terminalGate = createDeferred(); + const firstSession = createMockSession({ + sessionId: 'session-ring-live-repair', + lastEventId: 10, + submitPrompt: vi.fn(async () => ({ + promptId: 'prompt-1', + lastEventId: 10, + })), + events: async function* ringEvictedEvents() { + await ringEvicted.promise; + yield { + v: 1, + type: 'state_resync_required', + data: { + reason: 'ring_evicted', + lastDeliveredId: 10, + earliestAvailableId: 12, + }, + }; + }, + }); + const reloadedSession = createMockSession({ + sessionId: firstSession.sessionId, + hasActivePrompt: true, + lastEventId: 13, + replaySnapshot: { + compactedReplay: [], + liveJournal: [ + { + id: 11, + v: 1, + type: 'history_truncated', + promptId: 'prompt-1', + data: { + reason: 'replay_window_exceeded', + scope: 'live_journal', + truncatedEvents: 8, + retainedEvents: 1, + maxBytes: 1024, + maxEvents: 1, + fullTranscriptAvailable: true, + }, + }, + { + id: 12, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'partial tail' }, + }, + }, + }, + ], + }, + events: async function* terminalEvents( + options: { signal?: AbortSignal } = {}, + ) { + reloadedStreamStarted.resolve(); + await terminalGate.promise; + if (options.signal?.aborted) return; + yield { + id: 13, + v: 1, + type: 'turn_complete', + promptId: 'prompt-1', + data: { promptId: 'prompt-1', stopReason: 'end_turn' }, + } satisfies DaemonEvent; + await new Promise((resolve) => + options.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + }, + }); + const repairedSession = createMockSession({ + sessionId: firstSession.sessionId, + lastEventId: 13, + replaySnapshot: { + compactedReplay: [ + { + id: 10, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'long prompt' }, + }, + }, + }, + { + id: 11, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'complete answer' }, + }, + }, + }, + { + id: 13, + v: 1, + type: 'turn_complete', + promptId: 'prompt-1', + data: { promptId: 'prompt-1', stopReason: 'end_turn' }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(firstSession, reloadedSession, repairedSession); + + let actions: DaemonUiSessionActions | undefined; + const settlements: DaemonPromptSettledEvent[] = []; + function Harness() { + actions = useDaemonActions(); + useDaemonPromptSettled((event) => settlements.push(event)); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + const providerActions = requireActions(actions); + let promptResult: Promise | undefined; + await act(async () => { + promptResult = providerActions.sendPrompt('long prompt'); + await flushPromises(); + }); + await act(async () => { + ringEvicted.resolve(); + await reloadedStreamStarted.promise; + terminalGate.resolve(); + await flushPromises(); + }); + await act(async () => { + await vi.waitFor(() => + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(3), + ); + await flushPromises(); + }); + await act(async () => { + await vi.waitFor(() => expect(settlements).toHaveLength(1)); + await flushPromises(); + }); + await act(async () => { + await expect(promptResult).resolves.toEqual({ stopReason: 'end_turn' }); + }); + + expect(sdkMocks.MockDaemonSessionClient.load).toHaveBeenCalledTimes(3); + expect(settlements).toEqual([ + expect.objectContaining({ + sessionId: firstSession.sessionId, + promptId: 'prompt-1', + outcome: 'completed', + transcriptComplete: true, + }), + ]); + }); + + it('defers catch-up settlement until a truncated replay is repaired', async () => { + const ringEvicted = createDeferred(); + const repairLoad = createDeferred(); + const firstSession = createMockSession({ + sessionId: 'session-ring-catchup-repair', + lastEventId: 10, + submitPrompt: vi.fn(async () => ({ + promptId: 'prompt-1', + lastEventId: 10, + })), + events: async function* ringEvictedEvents() { + await ringEvicted.promise; + yield { + v: 1, + type: 'state_resync_required', + data: { + reason: 'ring_evicted', + lastDeliveredId: 10, + earliestAvailableId: 12, + }, + }; + }, + }); + const reloadedSession = createMockSession({ + sessionId: firstSession.sessionId, + hasActivePrompt: true, + lastEventId: 14, + replaySnapshot: { + compactedReplay: [ + { + id: 13, + v: 1, + type: 'turn_complete', + promptId: 'prompt-1', + data: { promptId: 'prompt-1', stopReason: 'end_turn' }, + }, + ], + liveJournal: [ + { + id: 11, + v: 1, + type: 'history_truncated', + promptId: 'prompt-1', + data: { + reason: 'replay_window_exceeded', + scope: 'live_journal', + truncatedEvents: 8, + retainedEvents: 1, + maxBytes: 1024, + maxEvents: 1, + fullTranscriptAvailable: true, + }, + }, + { + id: 12, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'partial catch-up tail' }, + }, + }, + }, + ], + }, + }); + const repairedSession = createMockSession({ + sessionId: firstSession.sessionId, + lastEventId: 14, + replaySnapshot: { + compactedReplay: [ + { + id: 10, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'long prompt' }, + }, + }, + }, + { + id: 12, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'complete catch-up answer' }, + }, + }, + }, + { + id: 13, + v: 1, + type: 'turn_complete', + promptId: 'prompt-1', + data: { promptId: 'prompt-1', stopReason: 'end_turn' }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(firstSession); + + let actions: DaemonUiSessionActions | undefined; + const settlements: DaemonPromptSettledEvent[] = []; + function Harness() { + actions = useDaemonActions(); + useDaemonPromptSettled((event) => settlements.push(event)); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => flushPromises()); + let reloadNumber = 0; + sdkMocks.MockDaemonSessionClient.load.mockImplementation( + async (client: unknown): Promise => { + reloadNumber += 1; + if (reloadNumber === 1) { + reloadedSession.client = client as MockClient; + return reloadedSession; + } + const repaired = await repairLoad.promise; + repaired.client = client as MockClient; + return repaired; + }, + ); + const providerActions = requireActions(actions); + let promptResult: Promise | undefined; + await act(async () => { + promptResult = providerActions.sendPrompt('long prompt'); + await flushPromises(); + }); + await act(async () => { + ringEvicted.resolve(); + await flushPromises(); + }); + await vi.waitFor(() => expect(reloadNumber).toBe(2)); + expect(settlements).toEqual([]); + + await act(async () => { + repairLoad.resolve(repairedSession); + await vi.waitFor(() => expect(settlements).toHaveLength(1)); + await flushPromises(); + }); + await act(async () => { + await expect(promptResult).resolves.toEqual({ stopReason: 'end_turn' }); + }); + + expect(settlements).toEqual([ + expect.objectContaining({ + sessionId: firstSession.sessionId, + promptId: 'prompt-1', + outcome: 'completed', + transcriptComplete: true, + }), + ]); + }); + it('rejects active prompts from replay turn_error after ring eviction', async () => { const ringEvicted = createDeferred(); const reloaded = createDeferred(); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 7af05c7503d..6e49da6d59e 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -51,6 +51,7 @@ import { eventPromptId, findLiveJournalRepairSuffix, findLiveJournalRepairTarget, + isLiveJournalMarker, type LiveJournalRepairSuffix, type LiveJournalRepairTarget, } from './live-journal-repair.js'; @@ -1036,6 +1037,15 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }, [], ); + const publishPendingRepairSettlement = useCallback( + (repair: LiveJournalRepairEpisode, transcriptComplete: boolean) => { + const settlement = repair.pendingSettlement; + if (!settlement) return; + repair.pendingSettlement = undefined; + publishPromptSettlement({ ...settlement, transcriptComplete }); + }, + [publishPromptSettlement], + ); const repairReloadRef = useRef< DaemonSessionActions['reloadSession'] | undefined >(undefined); @@ -1301,12 +1311,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { error instanceof Error ? error.message : String(error), recoverable: true, }); - if (repair.pendingSettlement) { - publishPromptSettlement({ - ...repair.pendingSettlement, - transcriptComplete: false, - }); - } + publishPendingRepairSettlement(repair, false); liveJournalRepairRef.current = undefined; }, ); @@ -2191,38 +2196,35 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { block.data['scope'] === 'live_journal', ); const existingRepair = liveJournalRepairRef.current; - liveJournalRepairRef.current = + const reuseExistingRepair = existingRepair?.target.signature === replayTarget.signature && - existingRepair.attempted - ? existingRepair - : { - sessionId: activeSession.sessionId, - target: replayTarget, - checkpoint: { - ...nextCheckpoint, - maxBlocks: committedMaxBlocks, - }, - ...(markerBlock - ? { markerBlockId: markerBlock.id } - : {}), - observedSnapshotEventIds: new Set( - liveJournal.flatMap((event) => - event.id === undefined ? [] : [event.id], - ), + existingRepair.attempted; + if (existingRepair && !reuseExistingRepair) { + publishPendingRepairSettlement(existingRepair, false); + } + liveJournalRepairRef.current = reuseExistingRepair + ? existingRepair + : { + sessionId: activeSession.sessionId, + target: replayTarget, + checkpoint: { + ...nextCheckpoint, + maxBlocks: committedMaxBlocks, + }, + ...(markerBlock ? { markerBlockId: markerBlock.id } : {}), + observedSnapshotEventIds: new Set( + liveJournal.flatMap((event) => + event.id === undefined ? [] : [event.id], ), - snapshotLastEventId: activeSession.lastEventId ?? 0, - lastObservedEventId: activeSession.lastEventId ?? 0, - terminalSeen: false, - attempted: false, - }; + ), + snapshotLastEventId: activeSession.lastEventId ?? 0, + lastObservedEventId: activeSession.lastEventId ?? 0, + terminalSeen: false, + attempted: false, + }; } else if (repairingEpisode) { liveJournalRepairRef.current = undefined; - if (repairingEpisode.pendingSettlement) { - publishPromptSettlement({ - ...repairingEpisode.pendingSettlement, - transcriptComplete: true, - }); - } + publishPendingRepairSettlement(repairingEpisode, true); } } else if (allUiEvents.length > 0) { store.dispatch(allUiEvents); @@ -2296,6 +2298,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { passiveAssistantDoneTimerRef, { requireBoundPromptId: true }, ); + if (activePromptSettled && restoredActivePrompt) { + settleRestoredActivePrompt(); + } if (!activePromptSettled) continue; const settlement = promptSettledFromTurnEvent( activeSession.sessionId, @@ -2309,8 +2314,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) { repair.pendingSettlement = settlement; repair.terminalSeen = true; + queueMicrotask(tryLiveJournalRepair); } else { - publishPromptSettlement(settlement); + publishPromptSettlement({ + ...settlement, + transcriptComplete: + !activeSession.replayDegraded && + !replayEvents.some(isLiveJournalMarker), + }); } } setConnection((c) => ({ ...c, catchingUp: undefined })); @@ -2791,6 +2802,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setPromptStatus, passiveAssistantDoneTimerRef, ); + if (activePromptSettled && restoredActivePrompt) { + settleRestoredActivePrompt(); + } let restoredPromptSettled = false; if ( !activePromptSettled && @@ -3472,6 +3486,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { clearNotices, addNotice, dismissNotice, + publishPendingRepairSettlement, publishPromptSettlement, setConnectionSynchronous, ]); @@ -3722,13 +3737,18 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setAttachSessionNonce, setNewSessionNonce, clearLiveJournalRepair: () => { - liveJournalRepairRef.current?.controller?.abort(); + const repair = liveJournalRepairRef.current; + if (repair) { + publishPendingRepairSettlement(repair, false); + repair.controller?.abort(); + } liveJournalRepairRef.current = undefined; }, }), [ addNotice, clientId, + publishPendingRepairSettlement, resolvedBaseUrl, resolvedToken, restartEventStreamOnPrompt, From 945c8065bcef6600b42601c491acdd913b2d01f5 Mon Sep 17 00:00:00 2001 From: dreamWB <22347282+dreamWB@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:50:41 +0800 Subject: [PATCH 04/12] fix(web-shell): harden prompt settlement recovery --- ...-28-web-shell-assistant-turn-settlement.md | 6 +- packages/web-shell/README.md | 3 +- packages/web-shell/client/App.test.tsx | 10 +- .../session/DaemonSessionProvider.test.tsx | 393 ++++++++++++++++++ .../daemon/session/DaemonSessionProvider.tsx | 152 ++++++- packages/webui/src/daemon/session/types.ts | 2 +- 6 files changed, 551 insertions(+), 15 deletions(-) diff --git a/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md index f5524651bc2..f7b6bb45992 100644 --- a/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md +++ b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md @@ -12,7 +12,9 @@ The callback is a generic Web Shell lifecycle contract. It is not tied to any em - `sessionId` and daemon-assigned `promptId`; their tuple is the stable idempotency key; - `outcome`: `completed`, `cancelled`, or `failed`; -- the daemon `stopReason` when a turn completed; +- the daemon `stopReason` for completed and cancelled turns; +- `error` (`{ message, code? }`) for failed turns; when no assistant content exists, this is the settlement's only failure diagnostic; +- `eventId`, the terminal SSE event cursor when available (it is not part of the idempotency key); - `transcriptComplete`, which is false when replay integrity is degraded or bounded live-journal repair could not restore the complete turn before failing or being discarded; - the final visible assistant message when it remains available in the committed current-session transcript; turns without assistant content and events delivered across a session switch omit it, while cancelled and failed turns may carry partial content. @@ -32,7 +34,7 @@ The daemon session provider publishes a settlement only after it has: Ordinary session load, branch/split transcript replay, and older-history pagination never publish settlements. A terminal received while reconnecting an already active prompt may publish because it is a previously unseen live lifecycle transition, not history playback. -Duplicate terminal delivery is suppressed for the lifetime of a mounted session provider using `(sessionId, promptId)`. Hosts must use the same key for durable idempotency across remounts. +Recent duplicate terminal delivery is suppressed by a bounded in-memory window in the mounted session provider using `(sessionId, promptId)`. Hosts must use the same key for durable idempotency across remounts and long-lived sessions. `prompt_cancelled` is a cancellation request, not a confirmed prompt terminal, and does not publish a settlement by itself. Waiting for permission or `ask_user_question` also does not publish. diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index 6f1c0584865..2df051644a8 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -195,7 +195,8 @@ export function App() { 宿主仍应使用该键做持久化幂等。 `outcome` 为 `completed`、`cancelled` 或 `failed`。取消和失败可能没有 Assistant -消息,也可能携带终止前的部分内容。`transcriptComplete: false` 表示 Web Shell 已收到 +消息,也可能携带终止前的部分内容;失败时 `error` 提供 daemon 终止原因,即使没有 +Assistant 消息也会保留该诊断。`transcriptComplete: false` 表示 Web Shell 已收到 终态,但 replay 已被标记为不完整,或受限 live journal 的修复在失败、会话切换或 repair episode 被替换前未能恢复完整 transcript。等待 permission 或 `ask_user_question` 不属于终态。Artifact 和 workspace projection 有各自的生命周期, diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 34a0284d63a..486044d9e98 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -12838,6 +12838,14 @@ describe('App session callbacks', () => { streaming: false, serverTimestamp: 42, }, + { + ...base, + id: 'assistant-empty-tail', + kind: 'assistant', + text: ' ', + streaming: false, + serverTimestamp: 43, + }, { ...base, id: 'assistant-subagent', @@ -12845,7 +12853,7 @@ describe('App session callbacks', () => { parentToolCallId: 'call-1', text: 'Subagent internal answer', streaming: false, - serverTimestamp: 43, + serverTimestamp: 44, }, ]; const onAssistantTurnSettled = vi.fn(); diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 747d569dd44..2dd6abcd923 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -6040,6 +6040,177 @@ describe('DaemonSessionProvider', () => { }); }); + it('publishes an incomplete settlement before replacing a repair episode', async () => { + const terminalGate = createDeferred(); + const repairLoad = createDeferred(); + const terminalEvent: DaemonEvent = { + id: 10, + v: 1, + type: 'turn_complete', + promptId: 'prompt-first', + data: { promptId: 'prompt-first', stopReason: 'end_turn' }, + }; + const initialSession = createMockSession({ + sessionId: 'session-live-repair-replaced', + hasActivePrompt: true, + lastEventId: 9, + replaySnapshot: { + compactedReplay: [], + liveJournal: [ + { + id: 8, + v: 1, + type: 'history_truncated', + promptId: 'prompt-first', + data: { + reason: 'replay_window_exceeded', + scope: 'live_journal', + truncatedEvents: 8, + retainedEvents: 1, + maxBytes: 1024, + maxEvents: 1, + fullTranscriptAvailable: true, + }, + }, + { + id: 9, + v: 1, + type: 'session_update', + promptId: 'prompt-first', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'partial first answer' }, + }, + }, + }, + ], + }, + events: async function* terminalEvents( + options: { signal?: AbortSignal } = {}, + ) { + await terminalGate.promise; + if (options.signal?.aborted) return; + yield terminalEvent; + await new Promise((resolve) => + options.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ); + }, + }); + const replacementSession = createMockSession({ + sessionId: initialSession.sessionId, + hasActivePrompt: true, + lastEventId: 13, + replaySnapshot: { + compactedReplay: [ + { + id: 1, + v: 1, + type: 'session_update', + promptId: 'prompt-first', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'first prompt' }, + }, + }, + }, + { + id: 2, + v: 1, + type: 'session_update', + promptId: 'prompt-first', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'complete first answer' }, + }, + }, + }, + terminalEvent, + { + id: 11, + v: 1, + type: 'session_update', + promptId: 'prompt-second', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'second prompt' }, + }, + }, + }, + ], + liveJournal: [ + { + id: 12, + v: 1, + type: 'history_truncated', + promptId: 'prompt-second', + data: { + reason: 'replay_window_exceeded', + scope: 'live_journal', + truncatedEvents: 12, + retainedEvents: 1, + maxBytes: 1024, + maxEvents: 1, + fullTranscriptAvailable: true, + }, + }, + { + id: 13, + v: 1, + type: 'session_update', + promptId: 'prompt-second', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'partial second answer' }, + }, + }, + }, + ], + }, + }); + sdkMocks.sessions.push(initialSession); + + const settlements: DaemonPromptSettledEvent[] = []; + function Harness() { + useDaemonPromptSettled((event) => settlements.push(event)); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + sdkMocks.MockDaemonSessionClient.load.mockImplementationOnce( + async (client: unknown): Promise => { + const replacement = await repairLoad.promise; + replacement.client = client as MockClient; + return replacement; + }, + ); + await act(async () => { + terminalGate.resolve(); + await flushPromises(); + }); + expect(settlements).toEqual([]); + + await act(async () => { + repairLoad.resolve(replacementSession); + await vi.waitFor(() => expect(settlements).toHaveLength(1)); + await flushPromises(); + }); + expect(settlements).toEqual([ + expect.objectContaining({ + sessionId: initialSession.sessionId, + promptId: 'prompt-first', + outcome: 'completed', + transcriptComplete: false, + }), + ]); + }); + it('does not repair a live marker for a non-matching queued terminal', async () => { const terminalGate = createDeferred(); const session = createMockSession({ @@ -11969,6 +12140,159 @@ describe('DaemonSessionProvider', () => { ]); }); + it('marks a live restored-prompt terminal incomplete after a degraded load', async () => { + const terminalDelivered = createDeferred(); + const session = createMockSession({ + sessionId: 'session-degraded-restored-live', + hasActivePrompt: true, + replayDegraded: true, + replaySnapshot: { + compactedReplay: [ + { + id: 11, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'partial restored answer' }, + }, + }, + }, + ], + liveJournal: [], + }, + events: async function* terminalEvents() { + yield { + id: 12, + v: 1, + type: 'turn_complete', + promptId: 'prompt-1', + data: { promptId: 'prompt-1', stopReason: 'end_turn' }, + } satisfies DaemonEvent; + terminalDelivered.resolve(); + }, + }); + sdkMocks.sessions.push(session); + + const settlements: DaemonPromptSettledEvent[] = []; + function Harness() { + useDaemonPromptSettled((event) => settlements.push(event)); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await terminalDelivered.promise; + await flushPromises(); + }); + + expect(settlements).toEqual([ + expect.objectContaining({ + sessionId: session.sessionId, + promptId: 'prompt-1', + outcome: 'completed', + transcriptComplete: false, + }), + ]); + }); + + it('publishes a restored prompt terminal received in a reconnect replay', async () => { + const ringEvicted = createDeferred(); + const reloaded = createDeferred(); + const firstSession = createMockSession({ + sessionId: 'session-restored-catchup', + hasActivePrompt: true, + lastEventId: 10, + replaySnapshot: { + compactedReplay: [ + { + id: 10, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'answer before reconnect' }, + }, + }, + }, + ], + liveJournal: [], + }, + events: async function* ringEvictedEvents() { + await ringEvicted.promise; + yield { + v: 1, + type: 'state_resync_required', + data: { + reason: 'ring_evicted', + lastDeliveredId: 10, + earliestAvailableId: 12, + }, + } satisfies DaemonEvent; + }, + }); + const reloadedSession = createMockSession({ + sessionId: firstSession.sessionId, + hasActivePrompt: false, + replaySnapshot: { + compactedReplay: [ + { + id: 11, + v: 1, + type: 'session_update', + promptId: 'prompt-1', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'final replayed answer' }, + }, + }, + }, + { + id: 12, + v: 1, + type: 'turn_complete', + promptId: 'prompt-1', + data: { promptId: 'prompt-1', stopReason: 'end_turn' }, + }, + ], + liveJournal: [], + }, + events: createPendingEvents(reloaded), + }); + sdkMocks.sessions.push(firstSession, reloadedSession); + + const settlements: DaemonPromptSettledEvent[] = []; + function Harness() { + useDaemonPromptSettled((event) => settlements.push(event)); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => { + ringEvicted.resolve(); + await reloaded.promise; + await flushPromises(); + }); + + expect(settlements).toEqual([ + expect.objectContaining({ + sessionId: firstSession.sessionId, + promptId: 'prompt-1', + outcome: 'completed', + transcriptComplete: true, + }), + ]); + }); + it('defers catch-up settlement until a truncated replay is repaired', async () => { const ringEvicted = createDeferred(); const repairLoad = createDeferred(); @@ -12210,10 +12534,12 @@ describe('DaemonSessionProvider', () => { let actions: DaemonUiSessionActions | undefined; let streamingState: ReturnType = 'idle'; let blocks: readonly DaemonTranscriptBlock[] = []; + const settlements: DaemonPromptSettledEvent[] = []; function Harness() { actions = useDaemonActions(); streamingState = useDaemonStreamingState(); blocks = useDaemonTranscriptBlocks(); + useDaemonPromptSettled((event) => settlements.push(event)); return null; } @@ -12265,6 +12591,16 @@ describe('DaemonSessionProvider', () => { }), ]), ); + expect(settlements).toEqual([ + { + sessionId: firstSession.sessionId, + promptId: 'prompt-1', + outcome: 'failed', + eventId: 13, + transcriptComplete: true, + error: { message: 'model overloaded', code: 'overloaded' }, + }, + ]); }); it('does not settle unaccepted prompts from historical replay turns', async () => { @@ -15770,6 +16106,63 @@ describe('DaemonSessionProvider', () => { ); }); + it('bounds prompt settlement duplicate retention', async () => { + const streamCompleted = createDeferred(); + sdkMocks.sessions.push( + createMockSession({ + events: async function* manyTerminalEvents() { + for (let index = 0; index <= 1024; index += 1) { + yield { + id: index + 1, + v: 1, + type: 'turn_complete', + promptId: `prompt-${index}`, + data: { + promptId: `prompt-${index}`, + stopReason: 'end_turn', + }, + } satisfies DaemonEvent; + } + yield { + id: 1026, + v: 1, + type: 'turn_complete', + promptId: 'prompt-0', + data: { promptId: 'prompt-0', stopReason: 'end_turn' }, + } satisfies DaemonEvent; + yield { + id: 1027, + v: 1, + type: 'turn_complete', + promptId: 'prompt-1024', + data: { promptId: 'prompt-1024', stopReason: 'end_turn' }, + } satisfies DaemonEvent; + streamCompleted.resolve(); + }, + }), + ); + const settlements: DaemonPromptSettledEvent[] = []; + + function Harness() { + useDaemonPromptSettled((event) => settlements.push(event)); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await streamCompleted.promise; + await flushPromises(); + }); + + expect(settlements).toHaveLength(1026); + expect( + settlements.filter((event) => event.promptId === 'prompt-0'), + ).toHaveLength(2); + expect( + settlements.filter((event) => event.promptId === 'prompt-1024'), + ).toHaveLength(1); + }); + it('isolates prompt settlement listener failures', async () => { const delivered = createDeferred(); const failingListener = vi.fn(() => { diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index 6e49da6d59e..f4bf8b403fd 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -169,6 +169,12 @@ interface LiveJournalRepairEpisode { controller?: AbortController; } +interface RestoredActivePromptTracking { + sessionId: string; + promptId?: string; + replayDegraded: boolean; +} + interface TranscriptHistoryMaterialization { blocks: readonly DaemonTranscriptBlock[]; nextOrdinal: number; @@ -193,6 +199,7 @@ const SESSION_TRANSCRIPT_PAGINATION_FEATURE = 'session_transcript_pagination'; const CLIENT_IDENTITY_FEATURE = 'client_identity'; const WORKSPACE_ACP_PREHEAT_FEATURE = 'workspace_acp_preheat'; const WORKSPACE_ACP_STATUS_FEATURE = 'workspace_acp_status'; +const MAX_PUBLISHED_PROMPT_SETTLEMENTS = 1024; // Cap the daemon-advertised restore retry delay: an unbounded value overflows // setTimeout's 2^31-1 ms limit (firing instantly, retry storm) or leaves the // UI stuck connecting for hours. @@ -279,6 +286,39 @@ function promptSettledFromTurnEvent( }; } +function findReplayActivePromptId( + events: readonly DaemonEvent[], +): string | undefined { + let activePromptId: string | undefined; + for (const event of events) { + const promptId = eventPromptId(event); + if (!promptId) continue; + if (event.type === 'turn_complete' || event.type === 'turn_error') { + if (activePromptId === promptId) activePromptId = undefined; + continue; + } + // Keep the first unresolved prompt. Later events can describe queued + // prompts while the restored turn is still running and must not steal its + // terminal correlation. + activePromptId ??= promptId; + } + return activePromptId; +} + +function findLastReplayTerminalPromptId( + events: readonly DaemonEvent[], +): string | undefined { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type !== 'turn_complete' && event?.type !== 'turn_error') { + continue; + } + const promptId = eventPromptId(event); + if (promptId) return promptId; + } + return undefined; +} + function getPersistedReplayRecordId(event: DaemonEvent): string | undefined { // A `history_truncated` marker may carry a `recordId` anchor stamped by // the daemon's compaction engine — the last recordId it saw before the @@ -1019,6 +1059,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const key = getPromptSettledKey(event.sessionId, event.promptId); if (publishedPromptSettlementsRef.current.has(key)) return; publishedPromptSettlementsRef.current.add(key); + if ( + publishedPromptSettlementsRef.current.size > + MAX_PUBLISHED_PROMPT_SETTLEMENTS + ) { + const oldestKey = publishedPromptSettlementsRef.current + .values() + .next().value; + if (oldestKey !== undefined) { + publishedPromptSettlementsRef.current.delete(oldestKey); + } + } const listeners = [...promptSettlementListenersRef.current]; if (listeners.length === 0) return; queueMicrotask(() => { @@ -1064,6 +1115,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const settledRestoredActivePromptSessionsRef = useRef< WeakSet >(new WeakSet()); + const restoredActivePromptTrackingRef = useRef< + RestoredActivePromptTracking | undefined + >(undefined); const eventOptionsRef = useRef({ suppressOwnUserEcho, includeRawEvent }); const reconnectConfigRef = useRef({ reconnectDelayMs, maxReconnectDelayMs }); // Aborts the reconnect backoff wait so a caller can force an immediate SSE @@ -1796,6 +1850,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { previousSessionId !== undefined && nextSession.sessionId !== previousSessionId ) { + restoredActivePromptTrackingRef.current = undefined; setPromptStatus('idle'); clearPassiveAssistantDoneTimer(passiveAssistantDoneTimerRef); needsStoreReset = true; @@ -1850,8 +1905,26 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // up) does not get mistaken for `turn_complete` (prompt finished). const restoredActivePromptSettled = settledRestoredActivePromptSessionsRef.current.has(activeSession); - let restoredActivePrompt = + const previouslyTrackedRestoredPrompt = + restoredActivePromptTrackingRef.current?.sessionId === + activeSession.sessionId + ? restoredActivePromptTrackingRef.current + : undefined; + if ( activeSession.hasActivePrompt === true && + !restoredActivePromptSettled + ) { + restoredActivePromptTrackingRef.current = { + sessionId: activeSession.sessionId, + ...(previouslyTrackedRestoredPrompt?.promptId + ? { promptId: previouslyTrackedRestoredPrompt.promptId } + : {}), + replayDegraded: activeSession.replayDegraded === true, + }; + } + let restoredActivePrompt = + (activeSession.hasActivePrompt === true || + previouslyTrackedRestoredPrompt !== undefined) && !restoredActivePromptSettled; const settleRestoredActivePrompt = () => { // `hasActivePrompt` is a load/resume snapshot on this session client. @@ -1859,6 +1932,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // reconnects for the same client; later prompts from this page are // still tracked independently in activePromptsRef. settledRestoredActivePromptSessionsRef.current.add(activeSession); + if ( + restoredActivePromptTrackingRef.current?.sessionId === + activeSession.sessionId + ) { + restoredActivePromptTrackingRef.current = undefined; + } restoredActivePrompt = false; }; const hasSessionActivePrompt = () => @@ -1888,6 +1967,22 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // only fires once with the fully-populated state. const { compactedReplay, liveJournal } = activeSession.replaySnapshot; const replayEvents = [...compactedReplay, ...liveJournal]; + if ( + restoredActivePrompt && + activeSession.hasActivePrompt === true && + restoredActivePromptTrackingRef.current?.sessionId === + activeSession.sessionId + ) { + const replayPromptId = findReplayActivePromptId(replayEvents); + if (replayPromptId) { + restoredActivePromptTrackingRef.current.promptId = replayPromptId; + } + } + const catchUpRestoredPromptId = + restoredActivePrompt && activeSession.hasActivePrompt !== true + ? (restoredActivePromptTrackingRef.current?.promptId ?? + findLastReplayTerminalPromptId(replayEvents)) + : undefined; const markerStillVisible = repairingEpisode?.markerBlockId !== undefined && store @@ -2288,6 +2383,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }); } for (const replayEvent of replayEvents) { + const replayPromptId = eventPromptId(replayEvent); const activePromptSettled = settleActivePromptFromTurnEvent( activePromptsRef.current, settledPromptsRef.current, @@ -2298,10 +2394,20 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { passiveAssistantDoneTimerRef, { requireBoundPromptId: true }, ); - if (activePromptSettled && restoredActivePrompt) { + const restoredPromptSettled = + !activePromptSettled && + restoredActivePrompt && + catchUpRestoredPromptId !== undefined && + replayPromptId === catchUpRestoredPromptId && + (replayEvent.type === 'turn_complete' || + replayEvent.type === 'turn_error'); + if ( + (activePromptSettled || restoredPromptSettled) && + restoredActivePrompt + ) { settleRestoredActivePrompt(); } - if (!activePromptSettled) continue; + if (!activePromptSettled && !restoredPromptSettled) continue; const settlement = promptSettledFromTurnEvent( activeSession.sessionId, replayEvent, @@ -2793,6 +2899,32 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ) { flushTranscriptSync(); } + const terminalPromptId = eventPromptId(event); + const trackedRestoredPrompt = + restoredActivePromptTrackingRef.current?.sessionId === + activeSession.sessionId + ? restoredActivePromptTrackingRef.current + : undefined; + if ( + restoredActivePrompt && + terminalPromptId && + event.type !== 'turn_complete' && + event.type !== 'turn_error' && + trackedRestoredPrompt !== undefined && + trackedRestoredPrompt.promptId === undefined + ) { + trackedRestoredPrompt.promptId = terminalPromptId; + } + const settlesTrackedRestoredPrompt = + restoredActivePrompt && + terminalPromptId !== undefined && + (event.type === 'turn_complete' || + event.type === 'turn_error') && + (trackedRestoredPrompt?.promptId === undefined || + trackedRestoredPrompt.promptId === terminalPromptId); + const restoredPromptReplayDegraded = + settlesTrackedRestoredPrompt && + trackedRestoredPrompt?.replayDegraded === true; const activePromptSettled = settleActivePromptFromTurnEvent( activePromptsRef.current, settledPromptsRef.current, @@ -2802,15 +2934,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { setPromptStatus, passiveAssistantDoneTimerRef, ); - if (activePromptSettled && restoredActivePrompt) { + if (activePromptSettled && settlesTrackedRestoredPrompt) { settleRestoredActivePrompt(); } let restoredPromptSettled = false; - if ( - !activePromptSettled && - restoredActivePrompt && - (event.type === 'turn_complete' || event.type === 'turn_error') - ) { + if (!activePromptSettled && settlesTrackedRestoredPrompt) { // A refreshed page restores an already-running prompt without a // local ActivePrompt entry or prompt promise to settle. The daemon // terminal event is still authoritative, so end the restored @@ -2954,10 +3082,14 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // exposing settlement to subscribers. flushTranscriptSync(); } - const settlement = promptSettledFromTurnEvent( + const rawSettlement = promptSettledFromTurnEvent( activeSession.sessionId, event, ); + const settlement = + rawSettlement && restoredPromptReplayDegraded + ? { ...rawSettlement, transcriptComplete: false } + : rawSettlement; const pendingRepair = liveJournalRepairRef.current; let settlementDelayedForRepair = false; if ( diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 0f6b386202e..c3a2c450910 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -205,7 +205,7 @@ export interface DaemonPromptSettledEvent { sessionId: string; promptId: string; outcome: DaemonPromptSettlementOutcome; - /** Daemon terminal reason. Present for `turn_complete` settlements. */ + /** Daemon terminal reason. Present for completed and cancelled settlements. */ stopReason?: string; /** Terminal SSE cursor when available; not a replacement for the idempotency key. */ eventId?: number; From bc1d3b67f38578d2289de2a331062da1c0216581 Mon Sep 17 00:00:00 2001 From: dreamWB <22347282+dreamWB@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:54:00 +0800 Subject: [PATCH 05/12] fix(web-shell): settle assistant turns in split view --- ...-28-web-shell-assistant-turn-settlement.md | 6 +- packages/web-shell/README.md | 3 + packages/web-shell/client/App.test.tsx | 58 ++++++++- packages/web-shell/client/App.tsx | 47 ++----- .../client/assistantTurnSettlement.ts | 118 ++++++++++++++++++ .../client/components/SplitView.test.tsx | 38 ++++++ .../web-shell/client/components/SplitView.tsx | 8 ++ packages/web-shell/client/customization.tsx | 7 +- packages/webui/src/daemon/session/types.ts | 5 +- 9 files changed, 246 insertions(+), 44 deletions(-) create mode 100644 packages/web-shell/client/assistantTurnSettlement.ts diff --git a/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md index f7b6bb45992..d804f8874e2 100644 --- a/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md +++ b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md @@ -14,10 +14,11 @@ The callback is a generic Web Shell lifecycle contract. It is not tied to any em - `outcome`: `completed`, `cancelled`, or `failed`; - the daemon `stopReason` for completed and cancelled turns; - `error` (`{ message, code? }`) for failed turns; when no assistant content exists, this is the settlement's only failure diagnostic; -- `eventId`, the terminal SSE event cursor when available (it is not part of the idempotency key); - `transcriptComplete`, which is false when replay integrity is degraded or bounded live-journal repair could not restore the complete turn before failing or being discarded; - the final visible assistant message when it remains available in the committed current-session transcript; turns without assistant content and events delivered across a session switch omit it, while cancelled and failed turns may carry partial content. +Transport cursors such as the daemon SSE `eventId` remain internal to the session layer. Web Shell explicitly projects the stable host contract instead of forwarding the internal event object. + The callback is optional. Existing `onSessionChange({ type: 'turn_complete' })` behavior remains unchanged. ## Lifecycle and ordering @@ -34,6 +35,8 @@ The daemon session provider publishes a settlement only after it has: Ordinary session load, branch/split transcript replay, and older-history pagination never publish settlements. A terminal received while reconnecting an already active prompt may publish because it is a previously unseen live lifecycle transition, not history playback. +The callback covers live turns from both the primary chat and interactive Split View panes. Every pane observes its own session provider, and a bounded Web Shell-level dispatcher suppresses duplicates when the primary session is also mounted in a pane. Merely opening a pane and replaying its transcript remains silent. + Recent duplicate terminal delivery is suppressed by a bounded in-memory window in the mounted session provider using `(sessionId, promptId)`. Hosts must use the same key for durable idempotency across remounts and long-lived sessions. `prompt_cancelled` is a cancellation request, not a confirmed prompt terminal, and does not publish a settlement by itself. Waiting for permission or `ask_user_question` also does not publish. @@ -62,5 +65,6 @@ Web Shell owns projection of the final visible assistant message and the public - `TC-12`: clearing or replacing a repair episode releases its held settlement once with `transcriptComplete: false`. - `TC-13`: degraded catch-up replay and unrecoverable live-journal markers publish with `transcriptComplete: false`. - `TC-14`: a terminal after reconnect consumes the restored-active snapshot and allows live or catch-up repair to finish. +- `TC-15`: a live turn in a Split View pane publishes with that pane's session and final message, while the shared dispatcher suppresses duplicate observation of the primary session. No visual UI changes are introduced, so browser screenshot validation is not applicable. Package unit tests, build, typecheck, and repository preflight are the delivery gates. diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index 2df051644a8..2f854417077 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -191,6 +191,9 @@ export function App() { 该回调来自 daemon 的 prompt 终态,并在终态 transcript 已提交后触发。普通历史加载、 分支回放和向前分页不会触发;断线期间遗漏、重连后补收的活动 prompt 终态可以触发。 +主聊天和 Split View 中的实时交互会话使用同一契约;仅打开 split pane 所产生的历史 +回放不会触发。如果主会话也显示在 pane 中,Web Shell 的共享分发器会避免同一终态在 +一个 Web Shell 实例内重复上报。 `(sessionId, promptId)` 是跨挂载幂等键。单个 Provider 挂载期间重复终态会被抑制, 宿主仍应使用该键做持久化幂等。 diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 486044d9e98..c6b58d18a61 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -1350,6 +1350,12 @@ vi.doMock('./components/SplitView', async () => { onExit?: () => void; sessionIds?: string[]; onPanesChange?: (ids: string[]) => void; + onAssistantTurnSettled?: (event: { + sessionId: string; + promptId: string; + outcome: 'completed'; + transcriptComplete: boolean; + }) => void; onPaneArtifactsChange?: (sessionId: string, artifacts: unknown[]) => void; onRightPanelOpen?: (request: unknown) => void; onOpenMonitor?: ( @@ -1448,6 +1454,21 @@ vi.doMock('./components/SplitView', async () => { }, 'remove panes', ), + React.createElement( + 'button', + { + 'data-testid': 'split-report-settlement', + type: 'button', + onClick: () => + props.onAssistantTurnSettled?.({ + sessionId: 'session-1', + promptId: 'prompt-shared', + outcome: 'completed', + transcriptComplete: true, + }), + }, + 'settlement', + ), React.createElement( 'button', { @@ -12876,7 +12897,6 @@ describe('App session callbacks', () => { promptId: 'prompt-1', outcome: 'completed', stopReason: 'end_turn', - eventId: 10, transcriptComplete: true, message: { id: 'assistant-final', @@ -12920,7 +12940,6 @@ describe('App session callbacks', () => { promptId: 'shared-prompt-id', outcome: 'completed', stopReason: 'end_turn', - eventId: 10, transcriptComplete: true, }); }); @@ -13006,12 +13025,45 @@ describe('App session callbacks', () => { sessionId: 'session-1', promptId: 'prompt-failed-before-content', outcome: 'failed', - eventId: 12, transcriptComplete: true, error: { message: 'model unavailable', code: 'model_error' }, }); }); + it('deduplicates a settlement observed by primary and split providers', async () => { + const onAssistantTurnSettled = vi.fn(); + const { container } = renderApp({ + onAssistantTurnSettled, + splitSessionIds: ['session-1'], + }); + await flush(); + + act(() => { + testState.promptSettledListener?.({ + sessionId: 'session-1', + promptId: 'prompt-shared', + outcome: 'completed', + eventId: 13, + transcriptComplete: true, + }); + }); + act(() => { + container + .querySelector( + '[data-testid="split-report-settlement"]', + ) + ?.click(); + }); + + expect(onAssistantTurnSettled).toHaveBeenCalledTimes(1); + expect(onAssistantTurnSettled).toHaveBeenCalledWith({ + sessionId: 'session-1', + promptId: 'prompt-shared', + outcome: 'completed', + transcriptComplete: true, + }); + }); + it('gates direct submissions and dispatches compatible submit events', async () => { const onSubmitBefore = vi.fn().mockResolvedValue(undefined); const onSessionChange = vi.fn(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index e3da4e22626..829f25dfd2b 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -19,7 +19,6 @@ import { useActions, useConnection, useDaemonFollowupSuggestion, - useDaemonPromptSettled, useSettings, useProviders, useSessionNotices, @@ -347,9 +346,12 @@ import { type WebShellBottomStatusItem, type WebShellPreparedSubmit, type WebShellSubmitSnapshot, - type WebShellAssistantMessageInfo, type WebShellAssistantTurnSettledEvent, } from './customization'; +import { + useAssistantTurnSettlementDispatcher, + useAssistantTurnSettlementProjection, +} from './assistantTurnSettlement'; import type { CommandDisplayCategoryOrder } from './utils/commandDisplay'; import { WebShellPortalRootContext } from './portalRoot'; import { CompactModeContext, TodoContextsProvider } from './WebShellContexts'; @@ -702,30 +704,6 @@ function getLatestUserBlock( return undefined; } -function getSettledAssistantMessage( - blocks: readonly DaemonTranscriptBlock[], - promptId: string, -): WebShellAssistantMessageInfo | undefined { - for (let index = blocks.length - 1; index >= 0; index -= 1) { - const block = blocks[index]; - if ( - block?.kind !== 'assistant' || - block.promptId !== promptId || - block.parentToolCallId !== undefined || - block.text.trim().length === 0 - ) { - continue; - } - return { - id: block.id, - content: block.text, - isStreaming: block.streaming ?? false, - timestamp: block.serverTimestamp ?? block.clientReceivedAt, - }; - } - return undefined; -} - function matchesUserMessageIdentity( block: DaemonTranscriptBlock | undefined, identity: TranscriptUserMessageIdentity | undefined, @@ -1066,7 +1044,8 @@ export interface WebShellProps { onStreamingStateChange?: (state: DaemonStreamingState) => void; /** * Called after an authoritative daemon prompt terminal has been committed to - * the transcript. Ordinary history replay does not emit this callback. + * the transcript in the primary chat or an interactive split pane. Ordinary + * history replay does not emit this callback. */ onAssistantTurnSettled?: (event: WebShellAssistantTurnSettledEvent) => void; /** @@ -2311,15 +2290,10 @@ export function App({ const CustomComposerFooter = renderComposerFooter; const store = useTranscriptStore(); const connection = useConnection(); - useDaemonPromptSettled((event) => { - const callback = onAssistantTurnSettled; - if (!callback) return; - const message = - connection.sessionId === event.sessionId - ? getSettledAssistantMessage(store.getSnapshot().blocks, event.promptId) - : undefined; - callback({ ...event, ...(message ? { message } : {}) }); - }); + const dispatchAssistantTurnSettled = useAssistantTurnSettlementDispatcher( + onAssistantTurnSettled, + ); + useAssistantTurnSettlementProjection(dispatchAssistantTurnSettled); const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot({ structuralOnly: true, }); @@ -13117,6 +13091,7 @@ export function App({ // callback stable to avoid looping SplitView's reporting // effect. onPanesChange={handleSplitPanesChange} + onAssistantTurnSettled={dispatchAssistantTurnSettled} includeOtherWorkspaces={!lockedWorkspaceCwd} workspaceCwd={lockedWorkspaceCwd} // Back returns to the Session Overview (the hub the split diff --git a/packages/web-shell/client/assistantTurnSettlement.ts b/packages/web-shell/client/assistantTurnSettlement.ts new file mode 100644 index 00000000000..a0984673a6b --- /dev/null +++ b/packages/web-shell/client/assistantTurnSettlement.ts @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useConnection, + useDaemonPromptSettled, + useTranscriptStore, + type DaemonPromptSettledEvent, +} from '@qwen-code/webui/daemon-react-sdk'; +import { useCallback, useRef } from 'react'; +import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; +import type { + WebShellAssistantMessageInfo, + WebShellAssistantTurnSettledEvent, +} from './customization'; + +const MAX_RECENT_WEB_SHELL_SETTLEMENTS = 1024; + +type AssistantTurnSettledHandler = ( + event: WebShellAssistantTurnSettledEvent, +) => void; + +function getSettledAssistantMessage( + blocks: readonly DaemonTranscriptBlock[], + promptId: string, +): WebShellAssistantMessageInfo | undefined { + for (let index = blocks.length - 1; index >= 0; index -= 1) { + const block = blocks[index]; + if ( + block?.kind !== 'assistant' || + block.promptId !== promptId || + block.parentToolCallId !== undefined || + block.text.trim().length === 0 + ) { + continue; + } + return { + id: block.id, + content: block.text, + isStreaming: block.streaming ?? false, + timestamp: block.serverTimestamp ?? block.clientReceivedAt, + }; + } + return undefined; +} + +function projectAssistantTurnSettlement( + event: DaemonPromptSettledEvent, + currentSessionId: string | undefined, + blocks: readonly DaemonTranscriptBlock[], +): WebShellAssistantTurnSettledEvent { + const message = + currentSessionId === event.sessionId + ? getSettledAssistantMessage(blocks, event.promptId) + : undefined; + return { + sessionId: event.sessionId, + promptId: event.promptId, + outcome: event.outcome, + ...(event.stopReason !== undefined ? { stopReason: event.stopReason } : {}), + transcriptComplete: event.transcriptComplete, + ...(message ? { message } : {}), + ...(event.error ? { error: event.error } : {}), + }; +} + +export function useAssistantTurnSettlementProjection( + onAssistantTurnSettled: AssistantTurnSettledHandler | undefined, +): void { + const store = useTranscriptStore(); + const connection = useConnection(); + useDaemonPromptSettled((event) => { + if (!onAssistantTurnSettled) return; + onAssistantTurnSettled( + projectAssistantTurnSettlement( + event, + connection.sessionId, + store.getSnapshot().blocks, + ), + ); + }); +} + +export function useAssistantTurnSettlementDispatcher( + onAssistantTurnSettled: AssistantTurnSettledHandler | undefined, +): AssistantTurnSettledHandler { + const recentKeysRef = useRef(new Set()); + const recentKeyOrderRef = useRef([]); + return useCallback( + (event) => { + if (!onAssistantTurnSettled) return; + const key = JSON.stringify([event.sessionId, event.promptId]); + if (recentKeysRef.current.has(key)) return; + recentKeysRef.current.add(key); + recentKeyOrderRef.current.push(key); + while ( + recentKeyOrderRef.current.length > MAX_RECENT_WEB_SHELL_SETTLEMENTS + ) { + const oldestKey = recentKeyOrderRef.current.shift(); + if (oldestKey !== undefined) recentKeysRef.current.delete(oldestKey); + } + onAssistantTurnSettled(event); + }, + [onAssistantTurnSettled], + ); +} + +export function AssistantTurnSettlementObserver({ + onAssistantTurnSettled, +}: { + onAssistantTurnSettled?: AssistantTurnSettledHandler; +}) { + useAssistantTurnSettlementProjection(onAssistantTurnSettled); + return null; +} diff --git a/packages/web-shell/client/components/SplitView.test.tsx b/packages/web-shell/client/components/SplitView.test.tsx index 228bc9ff9b9..64fd526bf65 100644 --- a/packages/web-shell/client/components/SplitView.test.tsx +++ b/packages/web-shell/client/components/SplitView.test.tsx @@ -62,6 +62,23 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ }, })); +vi.mock('../assistantTurnSettlement', () => ({ + AssistantTurnSettlementObserver: (props: any) => ( +