Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
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
54 changes: 54 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,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.
1 change: 1 addition & 0 deletions packages/web-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 接管对应操作:

Expand Down
7 changes: 7 additions & 0 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ const {
mockReleaseWebTerminal,
mockUseWorkspaceSessionLiveState,
mockUseDaemonSessionActivityBridge,
mockUseDaemonActivePromptBridge,
} = vi.hoisted(() => {
const connection: MockConnection = {
status: 'connected',
Expand Down Expand Up @@ -763,6 +764,7 @@ const {
mockReleaseDetachedWebTerminal: vi.fn(),
mockUseWorkspaceSessionLiveState: vi.fn(() => new Map()),
mockUseDaemonSessionActivityBridge: vi.fn(),
mockUseDaemonActivePromptBridge: vi.fn(),
};
});

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
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 @@ -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';
Expand Down Expand Up @@ -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
Comment thread
yiliang114 marked this conversation as resolved.
* 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 @@ -3059,6 +3067,7 @@ export function App({
composerInput,
composerInputVersion,
onSessionChange,
onAssistantTurnSettled,
prepareSubmit,
onSubmitBefore,
restartSseOnPrompt,
Expand All @@ -3067,6 +3076,7 @@ export function App({
lockedWorkspaceCwd,
lockedWorkspaceCapability,
}: AppProps = {}) {
useAssistantTurnSettlementProjection(onAssistantTurnSettled);
const [chatWidthMode, setChatWidthMode] =
useState<ChatWidthMode>(readChatWidthMode);
const [selectedLanguage, setSelectedLanguage] = useState<WebShellLanguage>(
Expand Down Expand Up @@ -18537,6 +18547,7 @@ export function App({
<SplitView
planControlVisible={visibleComposerToolbarActions.includes('plan')}
sessionIds={splitSessionIds}
onAssistantTurnSettled={onAssistantTurnSettled}
showSessionDetails={
(sidebarOptions.sessionActions?.items ??
DEFAULT_SESSION_ACTION_ITEMS).includes('details')
Expand Down
20 changes: 20 additions & 0 deletions packages/web-shell/client/adapters/transcriptToMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1806,6 +1806,26 @@ export function splitInsightSegments(text: string): InsightSegment[] | null {
return segments.length > 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,
Expand Down
Loading
Loading