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..e8a9e469492 --- /dev/null +++ b/docs/design/2026-08-28-web-shell-assistant-turn-settlement.md @@ -0,0 +1,73 @@ +# 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` for completed and cancelled turns; +- `error` (`{ message, code? }`) for failed turns; when no assistant content exists, this is the settlement's only failure diagnostic; +- `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 + +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, or classified the retained transcript as incomplete before discarding an unsuccessful repair. + +Live-journal truncation ownership is captured when the marker arrives, before transcript retention can evict its rendered status block. The client keeps a session-scoped marker-claim ledger: daemon event IDs are used when present, while id-less markers use a deterministic client key derived from their prompt ownership and payload. The transcript projection also preserves the envelope `promptId` on `history_truncated` status blocks. A marker is consumed only by its owning terminal (or conservatively by the first observed terminal when ownership is absent), so it can make that turn incomplete without tainting later turns. + +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. + +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. +- `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 the affected turn with `transcriptComplete: false` without tainting later complete turns in the same session. +- `TC-14`: a correlated terminal after reconnect consumes the restored-active snapshot and allows live or catch-up repair to finish; a mismatched live terminal neither consumes nor publishes for the restored turn. +- `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. +- `TC-16`: id-less live-journal markers retain envelope prompt ownership, and marker claims survive transcript-retention eviction and same-session rebuilds without tainting later turns. + +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/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 16878f53d24..61acb779657 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -1535,7 +1535,9 @@ function appendStatusBlock( ? { serverTimestamp: event.serverTimestamp } : {}), ...(event?.type === 'error' && event.code ? { code: event.code } : {}), - ...(event?.type === 'error' && event.promptId + ...((event?.type === 'error' || + (event?.type === 'status' && event.source === 'history_truncated')) && + event.promptId ? { promptId: event.promptId } : {}), ...(event?.type === 'error' && event.errorKind diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index b31bb309c4b..0979d4eae00 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -4608,6 +4608,7 @@ describe('daemon UI reducer state machine (PR-E)', () => { const events = normalizeDaemonEvent({ v: 1, type: 'history_truncated', + promptId: 'prompt-live', data: { reason: 'replay_window_exceeded', truncatedEvents: 4, @@ -4640,6 +4641,7 @@ describe('daemon UI reducer state machine (PR-E)', () => { expect(state.blocks).toMatchObject([ { kind: 'status', + promptId: 'prompt-live', text: expect.stringContaining('History truncated') as string, }, ]); diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index 5c8bc0b648b..751e79f6fd6 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -174,6 +174,37 @@ 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 终态可以触发。 +主聊天和 Split View 中的实时交互会话使用同一契约;仅打开 split pane 所产生的历史 +回放不会触发。如果主会话也显示在 pane 中,Web Shell 的共享分发器会避免同一终态在 +一个 Web Shell 实例内重复上报。 +`(sessionId, promptId)` 是跨挂载幂等键。单个 Provider 挂载期间重复终态会被抑制, +宿主仍应使用该键做持久化幂等。 + +`outcome` 为 `completed`、`cancelled` 或 `failed`。取消和失败可能没有 Assistant +消息,也可能携带终止前的部分内容;失败时 `error` 提供 daemon 终止原因,即使没有 +Assistant 消息也会保留该诊断。`transcriptComplete: false` 表示 Web Shell 已收到 +终态,但 replay 已被标记为不完整,或受限 live journal 的修复在失败、会话切换或 +repair episode 被替换前未能恢复完整 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 53964bc7bc8..26c4b37f471 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -399,6 +399,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, @@ -570,6 +581,11 @@ vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => { useActions: () => mockSessionActions, useConnection: () => mockConnection, useDaemonSessionOwnerGuard: () => ownerGuard, + useDaemonPromptSettled: ( + listener: typeof testState.promptSettledListener, + ) => { + testState.promptSettledListener = listener; + }, useDaemonFollowupSuggestion: () => ({ followupState: null, clear: mockFollowup.clear, @@ -1398,6 +1414,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?: ( @@ -1496,6 +1518,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', { @@ -5275,6 +5312,7 @@ beforeEach(() => { testState.inputAnnotations = undefined; testState.promptImages = undefined; testState.streamingState = 'idle'; + testState.promptSettledListener = undefined; testState.sessionHasActivePrompt = false; testState.blocks = []; testState.liveBlocks = undefined; @@ -12900,6 +12938,362 @@ 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, + }, + { + id: 'tool-1', + kind: 'tool', + toolCallId: 'call-1', + title: 'Lookup', + status: 'completed', + preview: { kind: 'generic' }, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }, + { + ...base, + id: 'assistant-final', + kind: 'assistant', + text: 'The final answer.', + streaming: false, + serverTimestamp: 42, + }, + { + ...base, + id: 'assistant-empty-tail', + kind: 'assistant', + text: ' ', + streaming: false, + serverTimestamp: 43, + }, + { + ...base, + id: 'assistant-subagent', + kind: 'assistant', + parentToolCallId: 'call-1', + text: 'Subagent internal answer', + streaming: false, + serverTimestamp: 44, + }, + ]; + 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', + 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', + 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('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', + 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('retries a shared settlement when the first host delivery throws', async () => { + const onAssistantTurnSettled = vi.fn().mockImplementationOnce(() => { + throw new Error('host callback failed'); + }); + const { container } = renderApp({ + onAssistantTurnSettled, + splitSessionIds: ['session-1'], + }); + await flush(); + + expect(() => { + act(() => { + testState.promptSettledListener?.({ + sessionId: 'session-1', + promptId: 'prompt-shared', + outcome: 'completed', + eventId: 13, + transcriptComplete: true, + }); + }); + }).toThrow('host callback failed'); + act(() => { + container + .querySelector( + '[data-testid="split-report-settlement"]', + ) + ?.click(); + }); + + expect(onAssistantTurnSettled).toHaveBeenCalledTimes(2); + expect(onAssistantTurnSettled.mock.results).toEqual([ + expect.objectContaining({ type: 'throw' }), + expect.objectContaining({ type: 'return' }), + ]); + }); + + it('delivers a settlement observed only by a split provider', async () => { + const onAssistantTurnSettled = vi.fn(); + const { container } = renderApp({ + onAssistantTurnSettled, + splitSessionIds: ['session-1'], + }); + await flush(); + + const splitSettlementButton = container.querySelector( + '[data-testid="split-report-settlement"]', + ); + expect(splitSettlementButton).not.toBeNull(); + act(() => splitSettlementButton!.click()); + + expect(onAssistantTurnSettled).toHaveBeenCalledOnce(); + expect(onAssistantTurnSettled).toHaveBeenCalledWith({ + sessionId: 'session-1', + promptId: 'prompt-shared', + outcome: 'completed', + transcriptComplete: true, + }); + }); + + it('bounds assistant settlement dispatcher duplicate retention', async () => { + const onAssistantTurnSettled = vi.fn(); + renderApp({ onAssistantTurnSettled }); + await flush(); + + act(() => { + for (let index = 0; index < 1024; index += 1) { + testState.promptSettledListener?.({ + sessionId: 'session-1', + promptId: `prompt-${index}`, + outcome: 'completed', + transcriptComplete: true, + }); + } + testState.promptSettledListener?.({ + sessionId: 'session-1', + promptId: 'prompt-0', + outcome: 'completed', + transcriptComplete: true, + }); + }); + expect(onAssistantTurnSettled).toHaveBeenCalledTimes(1024); + + act(() => { + testState.promptSettledListener?.({ + sessionId: 'session-1', + promptId: 'prompt-1024', + outcome: 'completed', + transcriptComplete: true, + }); + testState.promptSettledListener?.({ + sessionId: 'session-1', + promptId: 'prompt-0', + outcome: 'completed', + transcriptComplete: true, + }); + testState.promptSettledListener?.({ + sessionId: 'session-1', + promptId: 'prompt-1024', + outcome: 'completed', + transcriptComplete: true, + }); + }); + + expect(onAssistantTurnSettled).toHaveBeenCalledTimes(1026); + expect( + onAssistantTurnSettled.mock.calls.filter( + ([event]) => event.promptId === 'prompt-0', + ), + ).toHaveLength(2); + expect( + onAssistantTurnSettled.mock.calls.filter( + ([event]) => event.promptId === 'prompt-1024', + ), + ).toHaveLength(1); + }); + 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 d3a967b9dfe..02084a53d9c 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -354,7 +354,12 @@ import { type WebShellBottomStatusItem, type WebShellPreparedSubmit, type WebShellSubmitSnapshot, + type WebShellAssistantTurnSettledEvent, } from './customization'; +import { + useAssistantTurnSettlementDispatcher, + useAssistantTurnSettlementProjection, +} from './assistantTurnSettlement'; import type { CommandDisplayCategoryOrder } from './utils/commandDisplay'; import { WebShellPortalRootContext } from './portalRoot'; import { CompactModeContext, TodoContextsProvider } from './WebShellContexts'; @@ -1087,6 +1092,12 @@ 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 in the primary chat or an interactive split pane. 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 @@ -2027,6 +2038,7 @@ export function App({ shadowDom, onConnectionChange, onStreamingStateChange, + onAssistantTurnSettled, onError, onBugReport, hiddenSlashCommands, @@ -2347,10 +2359,14 @@ export function App({ const CustomComposerHeader = renderComposerHeader; const CustomComposerFooter = renderComposerFooter; const store = useTranscriptStore(); + const connection = useConnection(); + const dispatchAssistantTurnSettled = useAssistantTurnSettlementDispatcher( + onAssistantTurnSettled, + ); + useAssistantTurnSettlementProjection(dispatchAssistantTurnSettled); const { blocks, blockChangeSummary } = useAnimationFrameTranscriptSnapshot({ structuralOnly: true, }); - const connection = useConnection(); const logicalSessionKey = getLogicalSessionKey( connection.sessionId, connection.workspaceCwd, @@ -13329,6 +13345,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..b8fe1ed7207 --- /dev/null +++ b/packages/web-shell/client/assistantTurnSettlement.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useConnection, + useDaemonPromptSettled, + useTranscriptStore, + type DaemonPromptSettledEvent, +} from '@qwen-code/web-shell/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([]); + const deliveringKeysRef = useRef(new Set()); + return useCallback( + (event) => { + if (!onAssistantTurnSettled) return; + const key = JSON.stringify([event.sessionId, event.promptId]); + if (recentKeysRef.current.has(key) || deliveringKeysRef.current.has(key)) + return; + deliveringKeysRef.current.add(key); + try { + onAssistantTurnSettled(event); + } finally { + deliveringKeysRef.current.delete(key); + } + 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], + ); +} + +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 4e837cab8f1..adffc3eb4a6 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/web-shell/daemon-react-sdk', () => ({ }, })); +vi.mock('../assistantTurnSettlement', () => ({ + AssistantTurnSettlementObserver: (props: any) => ( +