diff --git a/docs/design/web-shell/web-shell-assistant-turn-settlement.md b/docs/design/web-shell/web-shell-assistant-turn-settlement.md new file mode 100644 index 00000000000..a02c5d4ca4b --- /dev/null +++ b/docs/design/web-shell/web-shell-assistant-turn-settlement.md @@ -0,0 +1,54 @@ +# Web Shell Assistant Turn Settlement + +## Problem + +Embedding hosts currently infer turn completion from Web Shell's rendered +streaming state. That state is suitable for loading UI, but it does not identify +the daemon prompt or distinguish completion, cancellation, and failure. + +## Contract + +`WebShellProps.onAssistantTurnSettled` reports: + +- the session id and daemon prompt id; +- `completed`, `cancelled`, or `failed`; +- the daemon stop reason when present; +- the final retained top-level assistant message when available; +- error details for failed prompts. + +The stable host idempotency key is `(sessionId, promptId)`. Existing +`onSessionChange({ type: 'turn_complete' })` behavior remains unchanged. + +## Delivery + +Each mounted `DaemonSessionProvider` publishes every prompt terminal observed +on its live SSE stream after the terminal transcript projection is committed. +Hosts that need submitter ownership correlate the prompt id with their submit +result. On reconnect, a terminal carried only by the replay snapshot publishes +when this provider previously admitted that prompt. That admission gate keeps +ordinary persisted-history loading silent while surviving session switches and +epoch-reset reloads that discard the active request controller. + +A lost connection or missing terminal does not publish a settlement: neither +condition proves how the prompt ended. Hosts receive only daemon terminal +events observed live or through reconnect replay. + +The provider suppresses duplicate terminals for its mounted lifetime. A host +can mount the same session in more than one provider, such as the main chat and +a Split View pane, so durable cross-provider suppression remains the host's +responsibility through the documented idempotency key. + +The final message is optional because bounded transcript retention, partial +history, cancellation, and failure can legitimately leave no retained assistant +text. Artifact and workspace projection have separate lifecycles and are not +implied to be settled by this callback. + +## Verification + +- completed, cancelled, and failed live terminals publish once; +- subscribers observe the terminal transcript projection before the callback; +- duplicate terminals publish once per provider mount; +- persisted history load is silent while reconnect catch-up publishes, + including a terminal that arrives through the replay snapshot; +- main chat and Split View providers forward the callback; +- existing `onSessionChange` behavior is unchanged. diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index ec3a4ab6226..c5dd53793d2 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -365,6 +365,7 @@ const projection = projectChatRecordsToDaemonTranscript(records); | `onBrandResolved` | `(brand: WebShellResolvedBrand) => void` | 品牌解析完成后触发,载荷只含 `name` 与 `logoDataUri`(不含 `logo` 节点),供宿主应用到自己的文档;shell 自身从不写 `document.title` 或 favicon | | `onSlashCommand` | `(command: WebShellSlashCommand) => boolean \| void` | 斜杠命令进入默认处理前触发;返回 `true` 时由宿主接管并跳过默认行为 | | `onSessionArtifactsChange` | `(change: WebShellSessionArtifactsChange) => void` | Session Artifact 初始恢复或变化后返回当前完整快照与 turn 投影 | +| `onAssistantTurnSettled` | `(event: WebShellAssistantTurnSettledEvent) => void` | daemon 权威终态提交后触发;多个 provider 可能重复上报,宿主按 `(sessionId, promptId)` 去重 | 宿主可以监听命令,也可以返回 `true` 接管对应操作: diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 54d230ce7e4..67e16126832 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -268,6 +268,7 @@ const { mockReleaseWebTerminal, mockUseWorkspaceSessionLiveState, mockUseDaemonSessionActivityBridge, + mockUseDaemonActivePromptBridge, } = vi.hoisted(() => { const connection: MockConnection = { status: 'connected', @@ -763,6 +764,7 @@ const { mockReleaseDetachedWebTerminal: vi.fn(), mockUseWorkspaceSessionLiveState: vi.fn(() => new Map()), mockUseDaemonSessionActivityBridge: vi.fn(), + mockUseDaemonActivePromptBridge: vi.fn(), }; }); @@ -1667,6 +1669,7 @@ vi.mock('./session-catalog/session-catalog-hooks', () => ({ authoritative: true, }), useDaemonSessionActivityBridge: mockUseDaemonSessionActivityBridge, + useDaemonActivePromptBridge: mockUseDaemonActivePromptBridge, // The Workspaces overview panel's per-row session counts; inert here. useSessionCatalogQuery: () => ({ page: undefined, @@ -10327,6 +10330,10 @@ beforeEach(() => { hasActivePrompt: testState.sessionHasActivePrompt, activeWorkState: undefined, })); + mockUseDaemonActivePromptBridge.mockReset(); + mockUseDaemonActivePromptBridge.mockImplementation( + () => testState.sessionHasActivePrompt, + ); mockWorkspace.status = 'connected'; mockWorkspace.brand = undefined; mockWorkspace.brandSettled = false; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 744f5e20317..e6bf88d2831 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -420,8 +420,10 @@ import { type WebShellBottomStatusItem, type WebShellPreparedSubmit, type WebShellSubmitSnapshot, + type WebShellAssistantTurnSettledEvent, type WebShellSessionArtifactsChange, } from './customization'; +import { useAssistantTurnSettlementProjection } from './assistant-turn-settlement'; import type { CommandDisplayCategoryOrder } from './utils/commandDisplay'; import { WebShellPortalRootContext } from './portalRoot'; import { CompactModeContext, TodoContextsProvider } from './WebShellContexts'; @@ -1373,6 +1375,12 @@ export interface WebShellProps { composerInputVersion?: number; /** Called when a session-level event occurs (rename, submit, turn complete). */ onSessionChange?: (event: SessionChangeEvent) => void; + /** + * Called for authoritative terminals observed live, or replayed for a prompt + * this provider admitted. Multiple mounted providers can report the same + * `(sessionId, promptId)`, so hosts should deduplicate by that key. + */ + onAssistantTurnSettled?: (event: WebShellAssistantTurnSettledEvent) => void; /** * Prepare the immutable payload for a daemon submission. Called once for a * direct or queued logical submit, after local command routing and before @@ -3059,6 +3067,7 @@ export function App({ composerInput, composerInputVersion, onSessionChange, + onAssistantTurnSettled, prepareSubmit, onSubmitBefore, restartSseOnPrompt, @@ -3067,6 +3076,7 @@ export function App({ lockedWorkspaceCwd, lockedWorkspaceCapability, }: AppProps = {}) { + useAssistantTurnSettlementProjection(onAssistantTurnSettled); const [chatWidthMode, setChatWidthMode] = useState(readChatWidthMode); const [selectedLanguage, setSelectedLanguage] = useState( @@ -18537,6 +18547,7 @@ export function App({ 0 ? segments : null; } +/** + * The visible assistant text of a block's text, with insight protocol frames + * (`insight_progress` / `insight_ready` / `insight_error`) stripped exactly as + * `transcriptBlocksToDaemonMessages` strips them. A payload-only block — one + * whose only content is such a frame — renders to no assistant text and + * therefore yields an empty string, so callers that publish a turn's final + * answer can skip it instead of leaking raw protocol JSON. + */ +export function assistantVisibleTextOf(text: string): string { + const segments = splitInsightSegments(text); + if (!segments) return text.trim(); + return segments + .filter( + (segment): segment is { kind: 'text'; text: string } => + segment.kind === 'text', + ) + .map((segment) => segment.text) + .join(' '); +} + function inferToolKind( toolName?: string, toolKind?: string, diff --git a/packages/web-shell/client/assistant-turn-settlement.test.tsx b/packages/web-shell/client/assistant-turn-settlement.test.tsx new file mode 100644 index 00000000000..644e4583aa2 --- /dev/null +++ b/packages/web-shell/client/assistant-turn-settlement.test.tsx @@ -0,0 +1,517 @@ +// @vitest-environment jsdom +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; +import type { WebShellAssistantTurnSettledEvent } from './customization'; +import type { + DaemonPromptSettledEvent, + DaemonPromptSettledListener, +} from './daemon/session/types'; + +const harness = vi.hoisted(() => ({ + sessionId: undefined as string | undefined, + blocks: [] as readonly unknown[], + listener: undefined as ((event: unknown) => void) | undefined, +})); + +vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({ + useConnection: () => ({ sessionId: harness.sessionId }), + useTranscriptStore: () => ({ + getSnapshot: () => ({ blocks: harness.blocks }), + }), +})); + +vi.mock('./daemon/session/DaemonSessionProvider.js', () => ({ + useDaemonPromptSettled: (listener: unknown) => { + harness.listener = listener as (event: unknown) => void; + }, +})); + +import { AssistantTurnSettlementObserver } from './assistant-turn-settlement'; +import { cleanupReact, mountReact } from './test/reactHarness'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +function assistantBlock( + id: string, + text: string, + init: { + promptId?: string; + parentToolCallId?: string; + streaming?: boolean; + } = {}, +): DaemonTranscriptBlock { + return { + id, + kind: 'assistant', + text, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + streaming: false, + ...init, + } as unknown as DaemonTranscriptBlock; +} + +function toolBlock(id: string, toolCallId: string): DaemonTranscriptBlock { + // The SDK reducer never stamps `promptId` on tool blocks, so a `promptId` + // filter over raw blocks drops every tool call of the turn. + return { + id, + kind: 'tool', + toolCallId, + title: 'Tool', + status: 'completed', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + } as unknown as DaemonTranscriptBlock; +} + +function userBlock( + id: string, + text: string, + promptId?: string, +): DaemonTranscriptBlock { + return { + id, + kind: 'user', + text, + promptId, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + } as unknown as DaemonTranscriptBlock; +} + +describe('assistant turn settlement projection', () => { + let published: WebShellAssistantTurnSettledEvent[] = []; + + beforeEach(() => { + published = []; + harness.sessionId = 'session-1'; + harness.blocks = []; + harness.listener = undefined; + }); + + afterEach(() => { + cleanupReact(); + }); + + function mountAndSettle(event: DaemonPromptSettledEvent) { + mountReact( + { + published.push(settled); + }} + />, + ); + const listener = harness.listener as + | DaemonPromptSettledListener + | undefined; + if (!listener) throw new Error('observer did not subscribe'); + act(() => { + listener(event); + }); + if (published.length !== 1) { + throw new Error( + `expected one published settlement, got ${published.length}`, + ); + } + return published[0]!; + } + + it('publishes the final top-level assistant message across a tool boundary', () => { + harness.blocks = [ + assistantBlock('assistant-1', 'Let me check.', { + promptId: 'prompt-live', + }), + toolBlock('tool-2', 'call-1'), + assistantBlock('assistant-3', 'The answer is 42.', { + promptId: 'prompt-live', + }), + ]; + + const settled = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-live', + outcome: 'completed', + stopReason: 'end_turn', + }); + + // Not the pre-tool block: the backward scan takes this prompt's last + // non-empty top-level block, whatever sits between them. + expect(settled).toEqual({ + sessionId: 'session-1', + promptId: 'prompt-live', + outcome: 'completed', + stopReason: 'end_turn', + message: { + id: 'assistant-3', + content: 'The answer is 42.', + isStreaming: false, + timestamp: 1, + }, + }); + }); + + it('does not return subagent-owned assistant text as the turn answer', () => { + harness.blocks = [ + assistantBlock('assistant-1', 'Delegating now.', { + promptId: 'prompt-live', + }), + toolBlock('tool-2', 'call-1'), + assistantBlock('assistant-3', 'subagent final text', { + promptId: 'prompt-live', + parentToolCallId: 'call-1', + }), + ]; + + const settled = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-live', + outcome: 'completed', + stopReason: 'end_turn', + }); + + expect(settled.message).toMatchObject({ + id: 'assistant-1', + content: 'Delegating now.', + }); + }); + + it('skips a whitespace-only assistant block after a tool boundary', () => { + // A whitespace-only block renders as nothing, so it must not win the + // backward scan as the turn's final message and drop the substantive answer + // one slot earlier. + harness.blocks = [ + assistantBlock('assistant-1', 'The answer is 42.', { + promptId: 'prompt-live', + }), + toolBlock('tool-2', 'call-1'), + assistantBlock('assistant-3', ' ', { promptId: 'prompt-live' }), + ]; + + const settled = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-live', + outcome: 'completed', + stopReason: 'end_turn', + }); + + expect(settled.message).toEqual({ + id: 'assistant-1', + content: 'The answer is 42.', + isStreaming: false, + timestamp: 1, + }); + }); + + it('does not publish an insight payload-only block as the turn answer', () => { + // The renderer strips insight protocol frames, so a block whose only + // content is such a frame (`/insight` progress/ready) produces no + // assistant text. Publishing its raw text would hand the host protocol + // JSON as the turn's final answer, permanently, while the real answer one + // slot earlier is never published. + harness.blocks = [ + assistantBlock('assistant-1', 'The answer is 42.', { + promptId: 'prompt-live', + }), + assistantBlock( + 'assistant-2', + '{"insight_ready":{"path":"/tmp/report.md"}}', + { promptId: 'prompt-live' }, + ), + ]; + + const settled = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-live', + outcome: 'completed', + stopReason: 'end_turn', + }); + + expect(settled.message).toEqual({ + id: 'assistant-1', + content: 'The answer is 42.', + isStreaming: false, + timestamp: 1, + }); + }); + + it('strips insight frames from a glued final block', () => { + // A block carrying an insight frame glued to trailing text renders to just + // that trailing text, so the published content must match the renderer's + // output rather than carry the raw frame alongside it. + harness.blocks = [ + assistantBlock( + 'assistant-1', + '{"insight_ready":{"path":"/tmp/report.md"}} after', + { promptId: 'prompt-live' }, + ), + ]; + + const settled = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-live', + outcome: 'completed', + stopReason: 'end_turn', + }); + + expect(settled.message).toEqual({ + id: 'assistant-1', + content: 'after', + isStreaming: false, + timestamp: 1, + }); + }); + + it('omits the message for a settlement from another session', () => { + harness.blocks = [ + assistantBlock('assistant-1', 'The answer is 42.', { + promptId: 'prompt-live', + }), + ]; + + const settled = mountAndSettle({ + sessionId: 'session-other', + promptId: 'prompt-live', + outcome: 'completed', + stopReason: 'end_turn', + }); + + expect(settled).not.toHaveProperty('message'); + }); + + it('does not settle a streaming assistant block as the turn answer', () => { + // This prompt's own final block is still open. Streaming means "not yet + // settled" rather than "keep looking": the settlement key is burned once, + // so publishing a fragment here could never be corrected afterwards. + harness.blocks = [ + assistantBlock( + 'assistant-1', + '{"insight_progress":{"stage":"planning","progress":0.5}} after', + { promptId: 'prompt-live', streaming: true }, + ), + ]; + + const settled = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-live', + outcome: 'completed', + stopReason: 'end_turn', + }); + + expect(settled).not.toHaveProperty('message'); + }); + + it('publishes this prompt its own final message while a later prompt streams', () => { + // The next turn is still typing, but its block is stamped `prompt-other` + // and so is never a candidate here. This turn's own final text is finished + // and attributable, so withholding it would leave a `completed` turn with no + // message — and no corrected callback can follow. + harness.blocks = [ + assistantBlock('assistant-1', 'The answer is 42.', { + promptId: 'prompt-live', + }), + assistantBlock('assistant-2', 'next turn still typing', { + promptId: 'prompt-other', + streaming: true, + }), + ]; + + const settled = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-live', + outcome: 'completed', + stopReason: 'end_turn', + }); + + expect(settled.message).toEqual({ + id: 'assistant-1', + content: 'The answer is 42.', + isStreaming: false, + timestamp: 1, + }); + }); + + it('publishes this prompt its own final message past a finished unstamped sibling', () => { + // Goal-runtime and background-notification turns never cross the + // `session/prompt` boundary that sets `entry.activePromptId`, so their + // frames are forwarded unstamped (`bridgeClient.ts:1066-1071`) and nothing + // can backfill a block that already finished (`sdk-typescript + // daemon/ui/transcript.ts:836-840`). The sibling is therefore foreign, but + // `assistant-1` satisfies every term of the selection — top-level, stamped + // `prompt-A`, non-empty — so this `completed` turn publishes its own answer. + // Asserted on exact content, not presence: glued text must fail here. + harness.blocks = [ + assistantBlock('assistant-1', 'The answer is 42.', { + promptId: 'prompt-A', + }), + assistantBlock('assistant-2', 'goal turn text'), + ]; + + const settled = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-A', + outcome: 'completed', + stopReason: 'end_turn', + }); + + expect(settled.message).toEqual({ + id: 'assistant-1', + content: 'The answer is 42.', + isStreaming: false, + timestamp: 1, + }); + }); + + it('publishes this prompt its own final message past an unstamped streaming sibling', () => { + // The unstamped sibling is still open, so a later delta can yet stamp it — + // but it is not this prompt's block, and `prompt-A`'s own final text is + // finished. The foreign partial text must not be published as this turn's + // answer, and its presence must not cost this turn its settlement. + harness.blocks = [ + assistantBlock('assistant-1', 'The answer is 42.', { + promptId: 'prompt-A', + }), + assistantBlock('assistant-2', 'still typing', { streaming: true }), + ]; + + const settled = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-A', + outcome: 'completed', + stopReason: 'end_turn', + }); + + expect(settled.message).toEqual({ + id: 'assistant-1', + content: 'The answer is 42.', + isStreaming: false, + timestamp: 1, + }); + }); + + it('gives two adjacent prompts their own messages, never a merged one', () => { + // A continuation carries no user prompt to echo (`bridge.ts` skips + // `echoPromptToSessionBus` when `isContinue`), so two top-level assistant + // blocks with different `promptId`s land adjacent with nothing between them + // and the render adapter merges them into ONE message that keeps the first + // block's `id` and concatenates both texts. Selecting on the blocks keeps + // each turn's own: distinct ids, and neither carrying the other's text. + harness.blocks = [ + assistantBlock('assistant-1', 'The answer is 42.', { + promptId: 'prompt-A', + }), + assistantBlock('assistant-2', 'next turn text', { promptId: 'prompt-B' }), + ]; + + const settledA = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-A', + outcome: 'completed', + stopReason: 'end_turn', + }); + expect(settledA.message).toEqual({ + id: 'assistant-1', + content: 'The answer is 42.', + isStreaming: false, + timestamp: 1, + }); + + cleanupReact(); + published = []; + const settledB = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-B', + outcome: 'completed', + stopReason: 'end_turn', + }); + // Not A's message id, and not the glued content: B must never inherit a + // message that also carries A's text. + expect(settledB.message).toEqual({ + id: 'assistant-2', + content: 'next turn text', + isStreaming: false, + timestamp: 1, + }); + }); + + it('still publishes each turn its own message when a user echo separates them', () => { + // The ordinary shape — a user echo between the two assistant blocks. Each + // prompt gets its own message id and its own text. + harness.blocks = [ + assistantBlock('assistant-1', 'The answer is 42.', { + promptId: 'prompt-A', + }), + userBlock('user-2', 'and next?', 'prompt-B'), + assistantBlock('assistant-3', 'next turn text', { promptId: 'prompt-B' }), + ]; + + const settledA = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-A', + outcome: 'completed', + stopReason: 'end_turn', + }); + expect(settledA.message).toEqual({ + id: 'assistant-1', + content: 'The answer is 42.', + isStreaming: false, + timestamp: 1, + }); + + cleanupReact(); + published = []; + const settledB = mountAndSettle({ + sessionId: 'session-1', + promptId: 'prompt-B', + outcome: 'completed', + stopReason: 'end_turn', + }); + expect(settledB.message).toEqual({ + id: 'assistant-3', + content: 'next turn text', + isStreaming: false, + timestamp: 1, + }); + }); + + it('publishes only the fields the host contract declares', () => { + harness.blocks = []; + // Internal-only widening: a field added to the internal event (and to no + // public type) must not reach hosts, at the top level or inside `error`. + const internalEvent = { + sessionId: 'session-1', + promptId: 'prompt-live', + outcome: 'failed', + errorKind: 'loop_detected', + error: { + message: 'Loop detected', + code: 'turn_error', + loopType: 'tool_loop', + }, + } as unknown as DaemonPromptSettledEvent; + + const settled = mountAndSettle(internalEvent); + + expect(Object.keys(settled).sort()).toEqual([ + 'error', + 'outcome', + 'promptId', + 'sessionId', + ]); + expect(settled).not.toHaveProperty('errorKind'); + expect(settled.error).toEqual({ + message: 'Loop detected', + code: 'turn_error', + }); + }); +}); diff --git a/packages/web-shell/client/assistant-turn-settlement.ts b/packages/web-shell/client/assistant-turn-settlement.ts new file mode 100644 index 00000000000..f27e3bbfdbf --- /dev/null +++ b/packages/web-shell/client/assistant-turn-settlement.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useConnection, + useTranscriptStore, +} from '@qwen-code/web-shell/daemon-react-sdk'; +import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; +import { useDaemonPromptSettled } from './daemon/session/DaemonSessionProvider.js'; +import type { DaemonPromptSettledEvent } from './daemon/session/types.js'; +import type { + WebShellAssistantMessageInfo, + WebShellAssistantTurnSettledEvent, +} from './customization.js'; +import { assistantVisibleTextOf } from './adapters/transcriptToMessages.js'; + +type AssistantTurnSettledHandler = ( + event: WebShellAssistantTurnSettledEvent, +) => void; + +function getSettledAssistantMessage( + blocks: readonly DaemonTranscriptBlock[], + promptId: string, +): WebShellAssistantMessageInfo | undefined { + // Select at the block layer, by identity: `blocks` is the array the reducer + // stamps `promptId` on, so this prompt's final assistant block is read off it + // directly. Deriving it from the render adapter instead needs ownership + // reconstructed from `sourceBlockIds` (the adapter drops `promptId`) and + // inherits the adapter's merge of consecutive top-level assistant blocks, + // which crosses turn boundaries — a continuation carries no user echo to + // separate them (`acp-bridge/src/bridge.ts:10582`). Every block shape this + // module did not hand-model then became a way to publish a foreign turn's + // text, an earlier non-final message of this turn, or nothing at all, under a + // `(sessionId, promptId)` key that is burned before the listener runs. The + // exclusion terms below are the SDK's own for this exact question + // (`sdk-typescript` `daemon/ui/transcript.ts`, + // `findFinalVisibleAssistantForPrompt`), kept local because exporting it + // would widen the `@qwen-code/sdk/daemon` public surface. + for (let index = blocks.length - 1; index >= 0; index -= 1) { + const block = blocks[index]; + // A subagent block belongs to its parent tool call; an unstamped block + // belongs to a turn that never crossed the `session/prompt` boundary + // setting `entry.activePromptId` (goal-runtime, background notification) or + // to restored history, which is unstamped by construction; a block stamped + // with another prompt id belongs to that prompt. None is this answer. + if ( + block?.kind !== 'assistant' || + block.parentToolCallId !== undefined || + block.promptId !== promptId + ) { + continue; + } + // Still streaming means "not yet settled", not "keep looking": publishing + // partial text is unrecoverable, as no corrected callback can follow. + if (block.streaming) return undefined; + // The renderer strips insight protocol frames from assistant block text, + // so the raw `block.text` is not the message a host would see. A + // payload-only block (an `/insight` progress/ready frame) renders to no + // assistant text, so the substantive answer one slot earlier is still this + // turn's final visible message — publishing the raw frame would leak + // protocol JSON as the answer. + const visibleText = assistantVisibleTextOf(block.text); + if (visibleText.length === 0) continue; + return { + id: block.id, + content: visibleText, + isStreaming: block.streaming, + 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; + // Field by field, not by spread: the published host contract only widens + // through a deliberate edit here, so an internal-only field added to + // `DaemonPromptSettledEvent` cannot silently reach every host. + return { + sessionId: event.sessionId, + promptId: event.promptId, + outcome: event.outcome, + ...(event.stopReason !== undefined ? { stopReason: event.stopReason } : {}), + ...(event.error + ? { + error: { + message: event.error.message, + ...(event.error.code !== undefined + ? { code: event.error.code } + : {}), + }, + } + : {}), + ...(message ? { message } : {}), + }; +} + +export function useAssistantTurnSettlementProjection( + onAssistantTurnSettled: AssistantTurnSettledHandler | undefined, +): void { + const store = useTranscriptStore(); + const connection = useConnection(); + useDaemonPromptSettled( + onAssistantTurnSettled + ? (event) => + onAssistantTurnSettled( + projectAssistantTurnSettlement( + event, + connection.sessionId, + store.getSnapshot().blocks, + ), + ) + : undefined, + ); +} + +export function AssistantTurnSettlementObserver({ + onAssistantTurnSettled, +}: { + onAssistantTurnSettled: AssistantTurnSettledHandler; +}) { + useAssistantTurnSettlementProjection(onAssistantTurnSettled); + return null; +} diff --git a/packages/web-shell/client/components/SplitView.tsx b/packages/web-shell/client/components/SplitView.tsx index d6d4125aed0..b6d7e01875d 100644 --- a/packages/web-shell/client/components/SplitView.tsx +++ b/packages/web-shell/client/components/SplitView.tsx @@ -45,6 +45,8 @@ import { workspaceLabelForCwd, } from '../utils/workspace'; import { isEditableTarget } from '../utils/dom'; +import { AssistantTurnSettlementObserver } from '../assistant-turn-settlement'; +import type { WebShellAssistantTurnSettledEvent } from '../customization'; import styles from './SplitView.module.css'; const MAX_PANES = MAX_SPLIT_PANES; @@ -62,6 +64,7 @@ export interface SplitViewProps { * each render would re-fire the reporting effect and loop. */ onPanesChange?: (sessionIds: string[]) => void; + onAssistantTurnSettled?: (event: WebShellAssistantTurnSettledEvent) => void; /** * Report panes surfacing approvals, including hidden panes. Keep stable while * consumer inputs are unchanged; a new callback receives the current list. @@ -115,6 +118,7 @@ export function SplitView({ sessionIds, showSessionDetails = true, onPanesChange, + onAssistantTurnSettled, onPendingPanesChange, onExit, onError, @@ -621,6 +625,11 @@ export function SplitView({ suppressOwnUserEcho restartEventStreamOnPrompt={restartSseOnPrompt} > + {onAssistantTurnSettled ? ( + + ) : null} { ); }); + it('does not record a stale-session removal in the current session turn navigation', async () => { + // A stale-session removal resolves against a foreign session, so its + // prompt id must never be written into the current session's + // turn-navigation store: recording it there would drop the current + // session's own turn from turn navigation. Deleting the + // `sessionId === undefined` guard on the `recordPromptRemoved` call makes + // this assertion fail. + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + sdkMocks.capabilities.mockResolvedValue({ + workspaceCwd: '/mock-workspace', + features: ['session_turn_navigation'], + }); + const session = createMockSession({ + sessionId: 'session-current', + clientId: 'client-current', + removePendingPrompt: vi.fn(async () => ({ removed: true })), + }); + sdkMocks.sessions.push(session); + let actions: DaemonSessionActions | undefined; + let navigationStore: + | ReturnType + | undefined; + let navigation: DaemonTurnNavigationSnapshot | undefined; + function Harness() { + actions = useDaemonActions(); + navigationStore = useDaemonTurnNavigationStore(); + navigation = useDaemonTurnNavigationState(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await vi.waitFor(() => expect(navigation?.mode).toBe('ready')); + }); + + const recordPromptRemoved = vi.spyOn( + navigationStore!, + 'recordPromptRemoved', + ); + + await expect( + requireActions(actions).removePendingPrompt('pending-old', { + sessionId: 'session-old', + }), + ).resolves.toEqual({ removed: true }); + + expect(recordPromptRemoved).not.toHaveBeenCalled(); + }); + it('routes mid-turn message removal through the matching session owner', async () => { const removeMidTurnMessage = vi.fn(async () => ({ removed: true })); const session = createMockSession({ @@ -10671,6 +10725,362 @@ describe('DaemonSessionProvider', () => { ]); }); + it('publishes a settlement when a replayed terminal settles a bound prompt', async () => { + // A ring eviction mid-turn reloads the session; the turn's terminal then + // arrives through the replay snapshot instead of the live stream. The + // snapshot is released after injection and SSE resumes from `lastEventId`, + // so the replay branch is the only place this settlement can be published. + const { sessions, resyncGate, reloaded } = createResyncReplayFixture({ + sessionId: 'session-settle-replay', + reason: 'ring_evicted', + terminalStopReason: 'end_turn', + }); + sdkMocks.sessions.push(...sessions); + const settlements: DaemonPromptSettledEvent[] = []; + let actions: DaemonUiSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + useDaemonPromptSettled((event) => { + settlements.push(event); + }); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + + let prompt: Promise | undefined; + await act(async () => { + prompt = requireActions(actions).sendPrompt('hello'); + await flushPromises(); + }); + expect(settlements).toEqual([]); + + await act(async () => { + resyncGate.resolve(); + await reloaded.promise; + await flushPromises(); + }); + + const pending = prompt; + if (!pending) throw new Error('prompt was not started'); + await act(async () => { + await expect(pending).resolves.toEqual({ stopReason: 'end_turn' }); + await flushPromises(); + }); + + expect(settlements).toEqual([ + { + sessionId: 'session-settle-replay', + promptId: 'prompt-1', + outcome: 'completed', + stopReason: 'end_turn', + }, + ]); + }); + + it('publishes the replayed terminal when an epoch reset discards the binding', async () => { + // `requestEpochResetReload` deletes the ActivePrompt before the reload, so + // `settleActivePromptFromTurnEvent` returns false for the replayed + // terminal; the admission-key gate must still publish it. + const { sessions, resyncGate, reloaded } = createResyncReplayFixture({ + sessionId: 'session-settle-epoch', + reason: 'epoch_reset', + terminalStopReason: 'end_turn', + }); + sdkMocks.sessions.push(...sessions); + const settlements: DaemonPromptSettledEvent[] = []; + let actions: DaemonUiSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + useDaemonPromptSettled((event) => { + settlements.push(event); + }); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + + let prompt: Promise | undefined; + await act(async () => { + prompt = requireActions(actions).sendPrompt('hello'); + await flushPromises(); + }); + expect(settlements).toEqual([]); + + await act(async () => { + resyncGate.resolve(); + await reloaded.promise; + await flushPromises(); + }); + + // The epoch reset aborts the local binding, so the submitter's promise + // resolves as `cancelled` while the replayed terminal publishes a + // `completed` settlement. Pin the cancelled resolution so the + // contradiction is observable instead of silently discarded. + const pending = prompt; + if (!pending) throw new Error('prompt was not started'); + await act(async () => { + await expect(pending).resolves.toEqual({ stopReason: 'cancelled' }); + await flushPromises(); + }); + + expect(settlements).toEqual([ + { + sessionId: 'session-settle-epoch', + promptId: 'prompt-1', + outcome: 'completed', + stopReason: 'end_turn', + }, + ]); + }); + + it('publishes a replayed cancelled terminal after the prompt was cancelled', async () => { + // `cancel()` removes the ActivePrompt (deletes it in `finally`) but the + // admission key survives; a `turn_complete{stopReason:'cancelled'}` that + // only reaches the client through replay must still be published. + const resyncGate = createDeferred(); + const reloaded = createDeferred(); + const firstSession = createMockSession({ + sessionId: 'session-settle-cancel', + submitPrompt: vi.fn(async () => ({ + promptId: 'prompt-1', + lastEventId: 9, + })), + events: async function* cancelThenResync( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + resyncGate.promise, + new Promise((resolve) => + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ), + ]); + if (opts.signal?.aborted) return; + yield { + id: 10, + v: 1, + type: 'state_resync_required', + data: { reason: 'ring_evicted' }, + } satisfies DaemonEvent; + }, + }); + const reloadedSession = createMockSession({ + sessionId: 'session-settle-cancel', + events: createPendingEvents(reloaded), + replaySnapshot: { + compactedReplay: [ + { + id: 11, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'replayed answer' }, + }, + }, + }, + { + id: 12, + v: 1, + type: 'turn_complete', + data: { promptId: 'prompt-1', stopReason: 'cancelled' }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(firstSession, reloadedSession); + const settlements: DaemonPromptSettledEvent[] = []; + let actions: DaemonUiSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + useDaemonPromptSettled((event) => { + settlements.push(event); + }); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + + let prompt: Promise | undefined; + await act(async () => { + prompt = requireActions(actions).sendPrompt('hello'); + await flushPromises(); + }); + await act(async () => { + await requireActions(actions).cancel(); + await flushPromises(); + }); + expect(settlements).toEqual([]); + + await act(async () => { + resyncGate.resolve(); + await reloaded.promise; + await flushPromises(); + }); + + // `cancel()` aborts the local binding, so the submitter's promise resolves + // `cancelled` — matching the replayed `cancelled` settlement below. Observe + // the promise to keep this consistent with the resync siblings above. + const pending = prompt; + if (!pending) throw new Error('prompt was not started'); + await act(async () => { + await expect(pending).resolves.toEqual({ stopReason: 'cancelled' }); + await flushPromises(); + }); + + expect(settlements).toEqual([ + { + sessionId: 'session-settle-cancel', + promptId: 'prompt-1', + outcome: 'cancelled', + stopReason: 'cancelled', + }, + ]); + }); + + it('does not publish settlements when a first attach replays a finished turn', async () => { + // Ordinary history loading stays silent: with no locally bound prompt the + // replay branch settles nothing, so it must not publish either. + const session = createMockSession({ + replaySnapshot: { + compactedReplay: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'already finished' }, + }, + }, + }, + { + id: 2, + v: 1, + type: 'turn_complete', + data: { promptId: 'prompt-1', stopReason: 'end_turn' }, + }, + ], + liveJournal: [], + }, + }); + sdkMocks.sessions.push(session); + const settlements: DaemonPromptSettledEvent[] = []; + + function Harness() { + useDaemonPromptSettled((event) => { + settlements.push(event); + }); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + await act(async () => { + await flushPromises(); + }); + + expect(settlements).toEqual([]); + }); + + it('defaults the settlement error code when a turn_error frame omits it', async () => { + // `matchTurnEvent` rejects the submitter's promise with + // `DaemonHttpError(500, data.code ?? 'turn_error', …)`. The published + // settlement reports the same failure, so it must apply the same default + // rather than dropping the field. + const turnError = createDeferred(); + const session = createMockSession({ + submitPrompt: vi.fn(async () => ({ + promptId: 'prompt-1', + lastEventId: 10, + })), + events: async function* codelessTurnError( + opts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + turnError.promise, + new Promise((resolve) => + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ), + ]); + if (opts.signal?.aborted) return; + yield { + v: 1, + id: 11, + type: 'turn_error', + timestamp: '2025-01-01T00:00:00.000Z', + sessionId: 'session-1', + data: { promptId: 'prompt-1', message: 'Loop detected' }, + }; + }, + }); + sdkMocks.sessions.push(session); + const settlements: DaemonPromptSettledEvent[] = []; + let actions: DaemonUiSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + useDaemonPromptSettled((event) => { + settlements.push(event); + }); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + + let rejection: unknown; + await act(async () => { + // Attach the handler up front: the terminal below rejects this promise + // inside the provider's own flush, before any assertion can await it. + const prompt = requireActions(actions).sendPrompt('hello'); + void prompt.catch((error: unknown) => { + rejection = error; + }); + await flushPromises(); + }); + + await act(async () => { + turnError.resolve(); + await flushPromises(); + }); + + // One failure, two reports: the submitter's `DaemonHttpError` exposes the + // code as `body`, the host's settlement as `error.code`. Both default to + // `turn_error` when the frame carries no code. + expect(rejection).toBeInstanceOf(DaemonHttpError); + expect((rejection as DaemonHttpError).message).toBe('Loop detected'); + expect((rejection as DaemonHttpError).body).toBe('turn_error'); + expect(settlements).toEqual([ + { + sessionId: 'session-1', + promptId: 'prompt-1', + outcome: 'failed', + error: { message: 'Loop detected', code: 'turn_error' }, + }, + ]); + }); + it('does not let replay state events overwrite fresh connection status', async () => { sdkMocks.workspaceProviders.mockResolvedValueOnce({ v: 1, @@ -22185,6 +22595,76 @@ function createPendingEvents( }; } +// Shared fixture for the resync-then-replay settlement tests: one submit +// bound to `prompt-1`, a live stream that reports `state_resync_required` +// once `resyncGate` resolves, and a reload whose snapshot carries the replayed +// terminal. Returning the sessions and gates together keeps the two settlement +// tests from re-inlining the snapshot literal; the submitter promise stays in +// the caller where it must be awaited. +function createResyncReplayFixture(opts: { + sessionId: string; + reason: string; + terminalStopReason: 'end_turn' | 'cancelled'; +}): { + sessions: MockSession[]; + resyncGate: ReturnType>; + reloaded: ReturnType>; +} { + const resyncGate = createDeferred(); + const reloaded = createDeferred(); + const firstSession = createMockSession({ + sessionId: opts.sessionId, + submitPrompt: vi.fn(async () => ({ + promptId: 'prompt-1', + lastEventId: 9, + })), + events: async function* resyncRequiredAfterGate( + eventOpts: { signal?: AbortSignal } = {}, + ) { + await Promise.race([ + resyncGate.promise, + new Promise((resolve) => + eventOpts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }), + ), + ]); + if (eventOpts.signal?.aborted) return; + yield { + id: 10, + v: 1, + type: 'state_resync_required', + data: { reason: opts.reason }, + } satisfies DaemonEvent; + }, + }); + const compactedReplay: DaemonEvent[] = [ + { + id: 11, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'replayed answer' }, + }, + }, + }, + ]; + compactedReplay.push({ + id: 12, + v: 1, + type: 'turn_complete', + data: { promptId: 'prompt-1', stopReason: opts.terminalStopReason }, + }); + const reloadedSession = createMockSession({ + sessionId: opts.sessionId, + events: createPendingEvents(reloaded), + replaySnapshot: { compactedReplay, liveJournal: [] }, + }); + return { sessions: [firstSession, reloadedSession], resyncGate, reloaded }; +} + function createTurnCompleteEvents( turnComplete: ReturnType>, promptId = 'prompt-1', diff --git a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx index 67f2ce43823..9947e6ee80b 100644 --- a/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx +++ b/packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx @@ -136,6 +136,9 @@ import type { DaemonSessionOwnerGuard, DaemonSessionProviderProps, DaemonProductSessionContext, + DaemonPromptSettledEvent, + DaemonPromptSettledListener, + DaemonPromptSettlementSubscribe, DaemonWorkspaceEventSignals, PendingSessionLoad, SettledPrompt, @@ -163,6 +166,9 @@ export type { DaemonNoticeOperation, DaemonNoticeSeverity, DaemonPromptImage, + DaemonPromptSettledEvent, + DaemonPromptSettledListener, + DaemonPromptSettlementOutcome, DaemonPromptStatus, DaemonSessionActions, DaemonSessionContextValue, @@ -729,6 +735,9 @@ const DaemonTurnNavigationContext = createContext< const DaemonPromptStatusContext = createContext( undefined, ); +const DaemonPromptSettlementContext = createContext< + DaemonPromptSettlementSubscribe | undefined +>(undefined); interface SessionNoticesValue { notices: readonly DaemonSessionNotice[]; dismissNotice(id: string): void; @@ -817,6 +826,28 @@ function useStableProductSessionContext( return stableRef.current.context; } +type LocallyBoundPromptIds = Map>; + +function bindPrompt( + bound: LocallyBoundPromptIds, + sessionId: string, + promptId: string, +): void { + const promptIds = bound.get(sessionId) ?? new Set(); + promptIds.add(promptId); + bound.set(sessionId, promptIds); +} + +function unbindPrompt( + bound: LocallyBoundPromptIds, + sessionId: string, + promptId: string, +): void { + const promptIds = bound.get(sessionId); + promptIds?.delete(promptId); + if (promptIds?.size === 0) bound.delete(sessionId); +} + export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const { baseUrl, @@ -1145,6 +1176,46 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const lastSessionIdRef = useRef(undefined); const activePromptsRef = useRef>(new Map()); const settledPromptsRef = useRef>(new Map()); + const locallyBoundPromptIdsRef = useRef(new Map()); + // Session whose locally bound prompts may have lost their terminal event + // across an epoch reset. The fresh load decides whether they are still live. + const epochResetSessionIdRef = useRef(undefined); + const promptSettlementListenersRef = useRef>( + new Set(), + ); + const publishedPromptSettlementsRef = useRef(new Set()); + const subscribeToPromptSettlement = + useCallback((listener) => { + promptSettlementListenersRef.current.add(listener); + return () => promptSettlementListenersRef.current.delete(listener); + }, []); + const publishPromptSettlement = useCallback( + (event: DaemonPromptSettledEvent) => { + const key = getPromptSettledKey(event.sessionId, event.promptId); + unbindPrompt( + locallyBoundPromptIdsRef.current, + event.sessionId, + event.promptId, + ); + if (publishedPromptSettlementsRef.current.has(key)) return; + publishedPromptSettlementsRef.current.add(key); + const listeners = [...promptSettlementListenersRef.current]; + 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 pendingSessionLoadRef = useRef( undefined, ); @@ -2887,6 +2958,25 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { transcriptAlreadyApplied: true, }, ); + // A terminal that arrives through replay is never re-delivered + // live: the snapshot is released below and SSE resumes from + // `lastEventId`. Publish here or a host keyed on + // `onAssistantTurnSettled` waits forever for a turn it can + // already see finished. The admission key survives active + // controller cleanup during reconnect and session switches, + // while keeping ordinary history loading silent. + const replaySettlement = promptSettledFromTurnEvent( + activeSession.sessionId, + replayEvent, + ); + if ( + replaySettlement && + locallyBoundPromptIdsRef.current + .get(replaySettlement.sessionId) + ?.has(replaySettlement.promptId) + ) { + publishPromptSettlement(replaySettlement); + } } if (sessionRef.current === activeSession) { for (const event of notificationReplayEvents) { @@ -2909,6 +2999,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // tens of MiB after adaptive journal growth. activeSession.consumeReplaySnapshot(); } + if (epochResetSessionIdRef.current === activeSession.sessionId) { + epochResetSessionIdRef.current = undefined; + if (!hasSessionActivePrompt()) { + locallyBoundPromptIdsRef.current.delete(activeSession.sessionId); + } + } setConnection((current) => ({ ...current, status: 'connected', @@ -3291,6 +3387,9 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { const active = activePromptsRef.current.get( activeSession.sessionId, ); + if (locallyBoundPromptIdsRef.current.has(activeSession.sessionId)) { + epochResetSessionIdRef.current = activeSession.sessionId; + } active?.controller.abort(); activePromptsRef.current.delete(activeSession.sessionId); if (restoredActivePrompt) { @@ -3517,6 +3616,12 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { // settle (and the restored-prompt / observer branches below) // dispatch. Guarded to turn terminals so steady streaming keeps // batching. + const pendingRepair = liveJournalRepairRef.current; + const repairTargetsTerminal = + pendingRepair?.sessionId === activeSession.sessionId && + (event.type === 'turn_complete' || + event.type === 'turn_error') && + eventPromptId(event) === pendingRepair.target.promptId; if ( event.type === 'turn_complete' || event.type === 'turn_error' @@ -3701,13 +3806,22 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ), ); } - const pendingRepair = liveJournalRepairRef.current; if ( - pendingRepair?.sessionId === activeSession.sessionId && - (event.type === 'turn_complete' || - event.type === 'turn_error') && - eventPromptId(event) === pendingRepair.target.promptId + event.type === 'turn_complete' || + event.type === 'turn_error' ) { + // `turn_error` adds its terminal error block after the earlier + // pre-settlement flush. Commit that projection before hosts run. + flushTranscriptSync(); + const settlement = promptSettledFromTurnEvent( + activeSession.sessionId, + event, + ); + if (settlement && !repairTargetsTerminal) { + publishPromptSettlement(settlement); + } + } + if (repairTargetsTerminal && pendingRepair) { pendingRepair.terminalSeen = true; queueMicrotask(tryLiveJournalRepair); } else if (pendingRepair?.terminalSeen) { @@ -4011,10 +4125,17 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { } continue; } - const failedSessionId = session?.sessionId; + const failedSessionId = + session?.sessionId ?? + reconnectSessionId ?? + epochResetSessionIdRef.current; const isAuthFailure = isAuthFailureHttpError(error); const isTerminal = isTerminalSessionHttpError(error); if (failedSessionId && (isAuthFailure || isTerminal)) { + locallyBoundPromptIdsRef.current.delete(failedSessionId); + if (epochResetSessionIdRef.current === failedSessionId) { + epochResetSessionIdRef.current = undefined; + } const active = activePromptsRef.current.get(failedSessionId); active?.controller.abort(); activePromptsRef.current.delete(failedSessionId); @@ -4343,6 +4464,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { clearNotices, addNotice, dismissNotice, + publishPromptSettlement, setConnectionSynchronous, ]); @@ -4446,6 +4568,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { errorStatus, ); } + locallyBoundPromptIdsRef.current.delete(deadSessionId); const active = activePromptsRef.current.get(deadSessionId); active?.controller.abort(); activePromptsRef.current.delete(deadSessionId); @@ -4635,6 +4758,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { liveJournalRepairRef.current = undefined; }, onPromptAdmitted: (owner, admission) => { + bindPrompt( + locallyBoundPromptIdsRef.current, + owner.sessionId, + admission.promptId, + ); if (sessionRef.current === owner) turnNotifications.admit(owner, admission.promptId, admission.label); if ( @@ -4664,10 +4792,16 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { turnNotifications.observe(owner, terminal, true); } }, - onPromptRemoved: (owner, promptId) => { - if (sessionRef.current === owner) + onPromptRemoved: (owner, promptId, sessionId) => { + unbindPrompt( + locallyBoundPromptIdsRef.current, + sessionId ?? owner.sessionId, + promptId, + ); + if (sessionId === undefined && sessionRef.current === owner) turnNotifications.remove(owner, promptId); if ( + sessionId === undefined && sessionRef.current === owner && turnNavigationStore.getSnapshot().sessionId === owner.sessionId ) { @@ -5063,7 +5197,11 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { - {children} + + {children} + @@ -5076,6 +5214,48 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ); } +function promptSettledFromTurnEvent( + sessionId: string, + event: DaemonEvent, +): DaemonPromptSettledEvent | undefined { + if (event.type !== 'turn_complete' && event.type !== 'turn_error') { + return undefined; + } + const promptId = eventPromptId(event); + if (!promptId) return undefined; + if (event.type === 'turn_error') { + const data = isRecord(event.data) ? event.data : {}; + return { + sessionId, + promptId, + outcome: 'failed', + error: { + // Same defaults `matchTurnEvent` applies when it turns this frame into + // the submitter's `DaemonHttpError`, so the rejected promise and the + // published settlement report one failure identically. A codeless + // `turn_error` is the common shape — the bridge omits `code` whenever + // `extractErrorCode` finds none. + message: getString(data, 'message') ?? 'Prompt failed', + code: getString(data, 'code') ?? 'turn_error', + }, + }; + } + const stopReason = + (event.data as DaemonTurnCompleteData | undefined)?.stopReason ?? + 'end_turn'; + return { + sessionId, + promptId, + outcome: + stopReason === 'cancelled' + ? 'cancelled' + : stopReason === 'error' + ? 'failed' + : 'completed', + stopReason, + }; +} + /** * Settle the session's active prompt from a `turn_complete` / `turn_error` * event. Dispatches `assistant.done` directly on `store`, so callers that have @@ -5499,6 +5679,23 @@ export function useDaemonPromptStatus(): DaemonPromptStatus { return promptStatus; } +export function useDaemonPromptSettled( + listener: DaemonPromptSettledListener | undefined, +): void { + const subscribe = useContext(DaemonPromptSettlementContext); + const listenerRef = useRef(listener); + listenerRef.current = listener; + useEffect( + () => subscribe?.((event) => listenerRef.current?.(event)), + [subscribe], + ); + if (listener !== undefined && !subscribe) { + throw new Error( + 'useDaemonPromptSettled must be used within DaemonSessionProvider', + ); + } +} + export function useDaemonConnection(): DaemonConnectionState { const connection = useContext(DaemonConnectionContext); if (!connection) { diff --git a/packages/web-shell/client/daemon/session/actions.test.ts b/packages/web-shell/client/daemon/session/actions.test.ts index 7a1519904dc..33f94edb5e5 100644 --- a/packages/web-shell/client/daemon/session/actions.test.ts +++ b/packages/web-shell/client/daemon/session/actions.test.ts @@ -502,6 +502,58 @@ describe('createDaemonSessionActions', () => { expect(onPromptRemoved).toHaveBeenCalledWith(session, 'prompt-1'); }); + it('notifies prompt removal on the stale-session branch with the foreign session id', async () => { + const session = createMockSession('session-current'); + const clientRemovePendingPrompt = vi.fn(async () => ({ removed: true })); + ( + session.client as unknown as { + removePendingPrompt: typeof clientRemovePendingPrompt; + } + ).removePendingPrompt = clientRemovePendingPrompt; + const onPromptRemoved = vi.fn(); + const { actions } = createActionsHarness({ session, onPromptRemoved }); + + await expect( + actions.removePendingPrompt('prompt-1', { sessionId: 'session-old' }), + ).resolves.toEqual({ removed: true }); + + expect(clientRemovePendingPrompt).toHaveBeenCalledWith( + 'session-old', + 'prompt-1', + ); + expect(onPromptRemoved).toHaveBeenCalledWith( + session, + 'prompt-1', + 'session-old', + ); + }); + + it('does not retire a prompt whose stale-session removal was refused', async () => { + // A refused removal (`removed: false`) means the prompt is still running + // in the foreign session; the `removed` guard must keep `onPromptRemoved` + // from firing, or the settlement admission key is retired while the turn + // keeps running. + const session = createMockSession('session-current'); + const clientRemovePendingPrompt = vi.fn(async () => ({ removed: false })); + ( + session.client as unknown as { + removePendingPrompt: typeof clientRemovePendingPrompt; + } + ).removePendingPrompt = clientRemovePendingPrompt; + const onPromptRemoved = vi.fn(); + const { actions } = createActionsHarness({ session, onPromptRemoved }); + + await expect( + actions.removePendingPrompt('prompt-1', { sessionId: 'session-old' }), + ).resolves.toEqual({ removed: false }); + + expect(clientRemovePendingPrompt).toHaveBeenCalledWith( + 'session-old', + 'prompt-1', + ); + expect(onPromptRemoved).not.toHaveBeenCalled(); + }); + it('does not report a stats error while the session is disconnected', async () => { const addNotice = vi.fn(); const { actions } = createActionsHarness({ addNotice }); diff --git a/packages/web-shell/client/daemon/session/actions.ts b/packages/web-shell/client/daemon/session/actions.ts index 77f8a1b293b..bc848189424 100644 --- a/packages/web-shell/client/daemon/session/actions.ts +++ b/packages/web-shell/client/daemon/session/actions.ts @@ -252,7 +252,15 @@ export interface CreateDaemonSessionActionsArgs { owner: DaemonSessionClient, promptId: string, ) => void; - onPromptRemoved?: (owner: DaemonSessionClient, promptId: string) => void; + onPromptRemoved?: ( + owner: DaemonSessionClient, + promptId: string, + // Present only when the removal bypassed the session object (the + // stale-session branch routes to `session.client.removePendingPrompt` and + // hands over the foreign owner session id, since `session` there is the + // *current* session, not the prompt's owner). + sessionId?: string, + ) => void; } export function getWorkspaceModelsAfterSessionClear( @@ -2723,10 +2731,14 @@ export function createDaemonSessionActions({ const session = sessionRef.current; if (!session) return { removed: false }; if (opts?.sessionId && session.sessionId !== opts.sessionId) { - return await session.client.removePendingPrompt( + const result = await session.client.removePendingPrompt( opts.sessionId, promptId, ); + if (result.removed) { + onPromptRemoved?.(session, promptId, opts.sessionId); + } + return result; } const result = await session.removePendingPrompt(promptId); if (result.removed) onPromptRemoved?.(session, promptId); diff --git a/packages/web-shell/client/daemon/session/types.ts b/packages/web-shell/client/daemon/session/types.ts index 405e92bf73a..afcc1d8c1f7 100644 --- a/packages/web-shell/client/daemon/session/types.ts +++ b/packages/web-shell/client/daemon/session/types.ts @@ -239,6 +239,31 @@ 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 completed and cancelled turns. */ + stopReason?: string; + error?: { + message: string; + code?: string; + }; +} + +export type DaemonPromptSettledListener = ( + event: DaemonPromptSettledEvent, +) => void; + +export type DaemonPromptSettlementSubscribe = ( + listener: DaemonPromptSettledListener, +) => () => void; + export type DaemonNoticeSeverity = 'info' | 'warning' | 'error'; export type DaemonNoticeCategory = diff --git a/packages/web-shell/client/index.tsx b/packages/web-shell/client/index.tsx index 7d0e23062ba..a78376376d9 100644 --- a/packages/web-shell/client/index.tsx +++ b/packages/web-shell/client/index.tsx @@ -245,6 +245,8 @@ export type { WebShellMarkdownChartCustomization, WebShellMarkdownCustomization, WebShellAssistantMessageInfo, + WebShellAssistantTurnOutcome, + WebShellAssistantTurnSettledEvent, WebShellAssistantTurnFooterRenderInfo, ArtifactImageRenderer, WebShellArtifactCustomization,