Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ba3ee48
feat(web-shell): expose assistant turn settlement
yiliang114 Sep 7, 2026
d56d14b
Merge remote-tracking branch 'origin/main' into codex/issue-10389-tur…
yiliang114 Sep 7, 2026
544405b
fix(web-shell): harden turn settlement callback
yiliang114 Sep 7, 2026
dba5292
fix(web-shell): skip unused settlement observer
yiliang114 Sep 7, 2026
b8e2966
Merge remote-tracking branch 'origin/main' into codex/issue-10389-tur…
yiliang114 Sep 7, 2026
eefd8ad
fix(web-shell): correct turn settlement message and replay delivery
yiliang114 Sep 7, 2026
557f3e0
fix(web-shell): preserve turn settlement across reloads
yiliang114 Sep 8, 2026
bb506df
fix(web-shell): omit unsettled assistant message
yiliang114 Sep 8, 2026
00e04d7
fix(web-shell): retire abandoned prompts and pin turn-settlement gates
yiliang114 Sep 8, 2026
2a7d82f
Merge branch 'main' into codex/issue-10389-turn-settlement
yiliang114 Sep 8, 2026
246d9cb
fix(web-shell): keep turn settlement daemon-authoritative
yiliang114 Sep 8, 2026
698181e
docs(web-shell): document settlement deduplication
yiliang114 Sep 8, 2026
df28be5
fix(web-shell): prettier-format the WebShell README table after addin…
yiliang114 Sep 8, 2026
1e1f3e8
Merge origin/main into codex/issue-10389-turn-settlement
yiliang114 Sep 8, 2026
c03562b
fix(web-shell): define the split-pane active-prompt bridge mock in Ap…
yiliang114 Sep 8, 2026
bb2be28
fix(web-shell): retire destroyed-turn settlements at the abandonment …
yiliang114 Sep 8, 2026
faac643
test(web-shell): pin both streaming guards in the settlement projection
yiliang114 Sep 8, 2026
b946ccd
test(web-shell): a refused stale-session removal keeps the admission key
yiliang114 Sep 8, 2026
46b5e87
fix(web-shell): add retireAbandonedPrompt to the session-load and hea…
yiliang114 Sep 9, 2026
7298af7
test(web-shell): pin settlement verdicts and guard stale-session removal
yiliang114 Sep 9, 2026
2bd9a7c
style(web-shell): format assistant-turn-settlement design doc
yiliang114 Sep 9, 2026
0be25ba
test(web-shell): dedupe resync-then-replay settlement fixtures
yiliang114 Sep 9, 2026
0eab4a8
Merge origin/main into codex/issue-10389-turn-settlement
yiliang114 Sep 9, 2026
190e770
fix(web-shell): settle abandoned prompt ledger
yiliang114 Sep 9, 2026
980ee46
Merge branch 'main' into codex/issue-10389-turn-settlement
yiliang114 Sep 9, 2026
125fd1e
fix(web-shell): keep turn settlement daemon-authoritative
yiliang114 Sep 9, 2026
68df9b0
fix(web-shell): require whole-message ownership to settle a turn
yiliang114 Sep 9, 2026
a959c10
fix(web-shell): treat a finished unstamped assistant block as foreign
yiliang114 Sep 9, 2026
669d40e
fix(web-shell): settle a turn from its own final assistant block
yiliang114 Sep 10, 2026
febc864
Merge origin/main into codex/issue-10389-turn-settlement
yiliang114 Sep 10, 2026
77d8a56
fix(web-shell): strip insight frames from a settled turn's answer
yiliang114 Sep 10, 2026
64a996b
Merge origin/main into codex/issue-10389-turn-settlement
yiliang114 Sep 12, 2026
c7c95e4
Merge origin/main into codex/issue-10389-turn-settlement
yiliang114 Sep 17, 2026
c383cff
Merge origin/main into codex/issue-10389-turn-settlement
yiliang114 Sep 18, 2026
55b0128
fix(web-shell): exclude system-notice sources from turn settlement se…
yiliang114 Sep 18, 2026
885e81e
test(web-shell): pin the settlement dedupe and live repair-suppressio…
yiliang114 Sep 18, 2026
c8853a1
fix(web-shell): ask the adapter which assistant blocks are system not…
yiliang114 Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/design/web-shell/web-shell-assistant-turn-settlement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 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.

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.
21 changes: 11 additions & 10 deletions packages/web-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,16 +248,17 @@ const projection = projectChatRecordsToDaemonTranscript(records);

### WebShell

| 属性 | 类型 | 说明 |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `onSessionIdChange` | `(sessionId: string \| undefined, workspaceId?: string, workspaceCwd?: string, sessionContext?: DaemonProductSessionContext) => void` | 当前 session、工作区或显式产品上下文变化时触发;standalone 和 Live 通过第四个参数上报 |
| `onSessionCreated` | `(sessionId: string) => Promise<void> \| void` | 新 session 创建后触发;完成前会阻塞 session 初始化和 prompt 提交,最长等待 30 秒 |
| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` |
| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 |
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |
| `onSlashCommand` | `(command: WebShellSlashCommand) => boolean \| void` | 斜杠命令进入默认处理前触发;返回 `true` 时由宿主接管并跳过默认行为 |
| `onSessionArtifactsChange` | `(change: WebShellSessionArtifactsChange) => void` | Session Artifact 初始恢复或变化后返回当前完整快照与 turn 投影 |
| 属性 | 类型 | 说明 |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `onSessionIdChange` | `(sessionId: string \| undefined, workspaceId?: string, workspaceCwd?: string, sessionContext?: DaemonProductSessionContext) => void` | 当前 session、工作区或显式产品上下文变化时触发;standalone 和 Live 通过第四个参数上报 |
| `onSessionCreated` | `(sessionId: string) => Promise<void> \| void` | 新 session 创建后触发;完成前会阻塞 session 初始化和 prompt 提交,最长等待 30 秒 |
| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` |
| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 |
| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 |
| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 |
| `onSlashCommand` | `(command: WebShellSlashCommand) => boolean \| void` | 斜杠命令进入默认处理前触发;返回 `true` 时由宿主接管并跳过默认行为 |
| `onSessionArtifactsChange` | `(change: WebShellSessionArtifactsChange) => void` | Session Artifact 初始恢复或变化后返回当前完整快照与 turn 投影 |
| `onAssistantTurnSettled` | `(event: WebShellAssistantTurnSettledEvent) => void` | daemon 权威终态提交后触发;多个 provider 可能重复上报,宿主按 `(sessionId, promptId)` 去重 |

宿主可以监听命令,也可以返回 `true` 接管对应操作:

Expand Down
11 changes: 11 additions & 0 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,8 +400,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';
Expand Down Expand Up @@ -1288,6 +1290,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
Expand Down Expand Up @@ -2958,6 +2966,7 @@ export function App({
composerInput,
composerInputVersion,
onSessionChange,
onAssistantTurnSettled,
prepareSubmit,
onSubmitBefore,
restartSseOnPrompt,
Expand All @@ -2966,6 +2975,7 @@ export function App({
lockedWorkspaceCwd,
lockedWorkspaceCapability,
}: AppProps = {}) {
useAssistantTurnSettlementProjection(onAssistantTurnSettled);
const [chatWidthMode, setChatWidthMode] =
useState<ChatWidthMode>(readChatWidthMode);
const [selectedLanguage, setSelectedLanguage] = useState<WebShellLanguage>(
Expand Down Expand Up @@ -17655,6 +17665,7 @@ export function App({
<WebShellCustomizationProvider value={customization}>
<SplitView
sessionIds={splitSessionIds}
onAssistantTurnSettled={onAssistantTurnSettled}
// Mirror live pane add/remove back up so switching away
// and re-entering restores the same panes. Keep this
// callback stable to avoid looping SplitView's reporting
Expand Down
248 changes: 248 additions & 0 deletions packages/web-shell/client/assistant-turn-settlement.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// @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;
}

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(
<AssistantTurnSettlementObserver
onAssistantTurnSettled={(settled) => {
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, and not both halves glued together: the tool
// call is a message boundary even though it carries no `promptId`.
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 assistant block after a tool call cannot merge (the
// tool case sets `needsNewContentMessage`), so it renders as its own empty
// message and would otherwise win the backward scan as the turn's final
// message — dropping the substantive answer.
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('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('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',
});
});
});
Loading
Loading