feat(web): render Code Mode sub-calls as native rows nested under the run_code row

The client indexes tool/code-dispatch events into
ConversationSnapshot.codeDispatches (parent callId -> ToolResultNode-shaped
sub-calls; live mux and history replay build the identical index). ChatView
renders each run_code parent as the new code variant (description summary,
program as the expanded monospace body) with its sub-dispatches as
always-visible indented rows — every sub-row dispatches through the SAME
keyed conversation.chat.toolview hole with the same GenericToolCard
fallback, so custom registrations (bash sample) take over sub-rows exactly
as top-level rows. The details panel resolves sub-callIds to full logged
args and complete output through the native path.

Evidence: fixture turn 64 + built-bundle jsdom snapshot, real-machinery
jsdom suites (nesting, error state, details, running parent, reference
stability), and a recorded code-mode browser e2e round (keyless replay +
aria golden). Scaffold gains a toolsMode patch knob.
This commit is contained in:
Tianyi Cui
2026-07-26 03:57:12 +08:00
parent 7f8c3cc6b8
commit 13f7c62318
27 changed files with 1253 additions and 20 deletions

View File

@@ -24,7 +24,7 @@ export type {
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, ComposerPhase, ContextMessageNode, ConversationNode,
AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode,
ConversationSnapshot, PendingPrompt, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'

View File

@@ -127,6 +127,17 @@ export type ConversationNode =
| ToolResultNode
| UnknownSurfaceNode
/**
* One `run_code` sub-dispatch materialized as a {@link ToolResultNode} so every
* consumer (tool rows, details panel) renders it through the exact components
* that render a native settled call. Never part of the surface `nodes` flow —
* sub-calls live under their parent via {@link ConversationSnapshot.codeDispatches}.
* `callId` is the deterministic sub-call id (`<parent>:code:<n>`); `call`
* carries the sub-tool name and its JSON-stringified logged arguments;
* `content`/`isError` are the sub-call's complete logged outcome.
*/
export type CodeSubCall = ToolResultNode
/** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall {
callId: string
@@ -212,6 +223,13 @@ export interface ConversationSnapshot {
foldDegraded: boolean
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
/**
* `run_code` sub-dispatches grouped under their parent callId, in dispatch
* order. Populated from in-window `tool/code-dispatch` events (live and
* replay identically); the per-parent array reference is stable across
* unrelated snapshot swaps (memo premise, same regime as `nodes`).
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[]
running: boolean
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */

View File

@@ -11,7 +11,7 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt,
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState, PendingPrompt,
PromptError, RunningToolCall, SessionIntentSnapshot, SessionIntentTarget,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
@@ -66,6 +66,11 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private frozenRev = 0
private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
@@ -611,6 +616,36 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Per-event side effects (right column of the §A.9 dispatch table):
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
// `tool/code-dispatch` is declared by the host-side dsh-tools plugin whose
// types cannot enter the client program (its host Context merges collide
// with the client's), so this wire consumer narrows it structurally —
// the same posture as every other cross-wire event payload.
if ((event.type as string) === 'tool/code-dispatch') {
// A sub-dispatch becomes a ToolResultNode so rows and the details
// panel reuse the native rendering path verbatim; it indexes under its
// parent run_code callId and never joins the surface flow.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const parent = data.parentCallId
const siblings = this.codeDispatches.get(parent) ?? []
const sub: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: event.time,
content: data.content, isError: data.isError,
callView: null, resultView: null,
}
this.codeDispatches.set(parent, [...siblings, sub])
this.dispatchesRev++
return
}
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
@@ -690,6 +725,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.callsRev++
this.frozenNodes = []
this.frozenRev++
this.codeDispatches = new Map()
this.dispatchesRev++
for (let i = 0; i < this.events.length; i++) {
const event = this.events[i]
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
@@ -722,6 +759,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
if (this.pendingCache === null || this.pendingCache.rev !== this.pendingRev) {
this.pendingCache = { rev: this.pendingRev, value: [...this.pending.values()] }
}
if (this.dispatchesCache === null || this.dispatchesCache.rev !== this.dispatchesRev) {
this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) }
}
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
@@ -730,6 +770,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
partial,
runningCalls: this.callsCache.value,
pending: this.pendingCache.value,
codeDispatches: this.dispatchesCache.value,
running: this.running,
composerPhase: derivePhase(
nodes.length > 0 || partial !== null || this.running || this.pendingCache.value.length > 0,

View File

@@ -26,6 +26,11 @@ export const ev = {
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
at(seq, {
type: 'tool/code-dispatch',
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args, isError, content: text(body) },
}),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>

View File

@@ -644,6 +644,63 @@ describe('resync', () => {
})
})
describe('run_code sub-dispatch indexing', () => {
it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls', description: '列目录' }, 'demo.txt'))
feed(ev.codeDispatch(9, 'p1', 2, 'read', { path: 'a.txt' }, 'Error: ENOENT', true))
const subs = session.getSnapshot().codeDispatches.get('p1')
expect(subs).toHaveLength(2)
expect(subs?.[0]).toMatchObject({
kind: 'tool-result', callId: 'p1:code:1',
call: { name: 'bash', argsRaw: '{"command":"ls","description":"列目录"}' },
isError: false, content: [{ type: 'text', text: 'demo.txt' }],
})
expect(subs?.[1]).toMatchObject({ callId: 'p1:code:2', isError: true })
// Sub-dispatches never join the surface flow.
expect(session.getSnapshot().nodes.some(n => n.kind === 'tool-result' && n.callId.includes(':code:'))).toBe(false)
})
it('rebuilds the same index from a history window (replay parity)', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse([
...plainTurn(0, 0, '问', '答'),
ev.turnStart(6, 1),
ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"return 1","description":"跑一个程序"}'),
ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'demo.txt'),
ev.toolResult(9, 1, 'p1', '{"done":true}'),
ev.turnEnd(10, 1),
])
await session.open()
const subs = session.getSnapshot().codeDispatches.get('p1')
expect(subs).toHaveLength(1)
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } })
})
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatch(8, 'p1', 1, 'bash', { command: 'ls' }, 'x'))
const before = session.getSnapshot()
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '流式'))
const after = session.getSnapshot()
expect(after.codeDispatches).toBe(before.codeDispatches)
feed(ev.codeDispatch(11, 'p1', 2, 'read', { path: 'a' }, 'y'))
expect(session.getSnapshot().codeDispatches).not.toBe(after.codeDispatches)
expect(session.getSnapshot().codeDispatches.get('p1')).toHaveLength(2)
})
})
describe('reference stability (the memo contract)', () => {
it('keeps unchanged node references across an append and swaps the snapshot object', async () => {
const { api, session } = makeSession()