refactor(client): localize nested Tool call trees

This commit is contained in:
imccyu
2026-08-08 17:08:22 +08:00
parent 5e0b7571e6
commit 9ed33dfe4f
48 changed files with 586 additions and 413 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: bb85d3c7b45eb0d82d789a9133bba787f1d6c5e9
README.zh.md: 18aedbb487a490c85ce68ccc460fd82218157843
README.md: fad0f6e4f948ebf412b2bd837a4f68e6dc805ec8
README.zh.md: 4ede12a320082a5023ec121306650fce72a12663

View File

@@ -44,9 +44,9 @@ Because the projection is log-ordered, the node array is seq-monotonic by constr
`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan.
## Code Mode sub-dispatch index
## Code Mode child-call tree
`ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the transcript `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps.
Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Runtime's `ToolCallTree` privately maintains the parent-callId-to-children index: a `tool/code-dispatch-start` event lands as a `RunningToolCall`, and the matching `tool/code-dispatch` settlement replaces it in place with a `ToolResultNode` whose `callTime` comes from the paired start. When the start fell outside the replay window, the settlement appends directly with `callTime: null`; Runtime never fabricates a zero duration. Live mux frames and history replay share this fold and tree projection, and child calls never become independent roots in transcript `nodes`. A child update copies only its ancestor path to the owning root; unchanged siblings and other roots retain object identity.
## Session title projection

View File

@@ -44,9 +44,9 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn``step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。
## Code Mode 子调用索引
## Code Mode 子调用
`ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时。live mux 帧与历史回放构建相同的索引;子调用永不进入 transcript `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。
每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Runtime 的 `ToolCallTree` 私下维护 parent callId 到 child 的索引`tool/code-dispatch-start` 事件落成 `RunningToolCall`,对应的 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode``callTime` 来自成对 start 事件start 落在回放窗口之外时,完结事件会以 `callTime: null` 直接追加,绝不伪造零耗时。live mux 帧与历史回放共用这套 fold 和树投影;子调用不会成为 transcript `nodes` 中的独立 root。一次 child 变化只会复制从该 child 到所属 root 的祖先链,未变化的 sibling 和其他 root 保持对象引用稳定。
## Session 标题投影

View File

@@ -8,7 +8,7 @@ import { SessionsService } from './sessions/service.ts'
import type { SessionListState } from './sessions/service.ts'
import { SessionHistoryService } from './session-history/service.ts'
import { WorkspacesService } from './workspaces/service.ts'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
import type { ConversationSnapshot } from './sessions/conversation.ts'
import type { UseProjection } from './sessions/projection-store.ts'
export { SlotsService } from './slots.ts'
@@ -49,10 +49,10 @@ export type {
} from './contract/store.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
AssistantTiming, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
ConversationContext, ConversationContextOriginKind,
@@ -89,13 +89,6 @@ declare module '@deepseek-ai/dsh-type-meta' {
/** The conversation-snapshot selector hook supplied to session-scoped UI entries. */
export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
/**
* One tool call as the chat flow renders it: still-running (spinner card) or
* settled (result node). The fold produces both shapes; toolview components
* narrow on the discriminant fields.
*/
export type ToolCallBlock = RunningToolCall | ToolResultNode
declare module '@deepseek-ai/dsh-client-ui-slots' {
/**
* Session standard kit, real members (ui-slots declares the empty seat;

View File

@@ -1,4 +1,3 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import {
SurfaceManager, isSurfaceEligibleType, isSurfaceEvent,
@@ -7,7 +6,7 @@ import type {
HistoryEntry, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type {
AssistantRequestConfig, AssistantTiming, CodeSubCall, ConversationNode,
AssistantRequestConfig, AssistantTiming, ConversationNode,
PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts'
@@ -20,6 +19,7 @@ import type { ConversationPromptSnapshot } from '../sessions/request-inspection.
import { PartialAccumulator } from '../sessions/partial.ts'
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
import { ToolCallTree } from '../sessions/tool-call-tree.ts'
interface CallIndexEntry {
name: string
@@ -41,7 +41,6 @@ export interface ConversationHistoryProjection {
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
@@ -177,6 +176,7 @@ function materializeNode(
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
subCalls: [],
}
}
default:
@@ -188,74 +188,22 @@ function materializeNode(
}
/* jscpd:ignore-end */
function projectTransient(entries: readonly HistoryEntry[]): Pick<
interface TransientProjection extends Pick<
ConversationHistoryProjection,
'interruptedNodes' | 'partial' | 'runningCalls' | 'codeDispatches'
'interruptedNodes' | 'partial' | 'runningCalls'
> {
toolCallTree: ToolCallTree
}
function projectTransient(entries: readonly HistoryEntry[]): TransientProjection {
let partial: PartialAccumulator | null = null
const openCalls = new Map<string, RunningToolCall>()
const interruptedNodes: ConversationNode[] = []
const codeDispatches = new Map<string, readonly CodeSubCall[]>()
const toolCallTree = new ToolCallTree()
for (const entry of entries) {
const { event } = entry
if ((event.type as string) === 'tool/code-dispatch-start') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const siblings = codeDispatches.get(data.parentCallId) ?? []
// The independent replay emits the same public running-call shape as
// Chat without reading or mutating Session's live index.
/* jscpd:ignore-start */
codeDispatches.set(data.parentCallId, [...siblings, {
callId: data.subCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0,
step: 0,
time: event.time,
callView: null,
}])
/* jscpd:ignore-end */
continue
}
if ((event.type as string) === 'tool/code-dispatch') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
// History independently reproduces the public settled-call shape instead
// of consuming Session's live code-dispatch projection.
/* jscpd:ignore-start */
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: started?.time ?? null,
content: data.content,
isError: data.isError,
callView: null,
resultView: null,
}
codeDispatches.set(
data.parentCallId,
at === -1
? [...siblings, settled]
: siblings.map((sub, index) => index === at ? settled : sub),
)
/* jscpd:ignore-end */
continue
}
if (toolCallTree.apply(event)) continue
switch (event.type) {
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
@@ -280,6 +228,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
step: event.data.step,
time: event.time,
callView: entry.view?.for === 'call' ? entry.view.view : null,
subCalls: [],
})
/* jscpd:ignore-end */
break
@@ -317,6 +266,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView,
resultView: null,
subCalls: [],
})
/* jscpd:ignore-end */
}
@@ -331,7 +281,7 @@ function projectTransient(entries: readonly HistoryEntry[]): Pick<
interruptedNodes,
partial: partial?.toPartial() ?? null,
runningCalls: [...openCalls.values()],
codeDispatches,
toolCallTree,
}
}
@@ -462,9 +412,17 @@ export function projectConversationHistory(
}
}
const transient = projectTransient(entries)
const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes)
const projectedContexts = contexts.map((context): ConversationContext => {
const nodes = transient.toolCallTree.projectNodes(context.nodes)
return nodes === context.nodes ? context : { ...context, nodes }
})
return {
eventNodes,
contexts,
...projectTransient(entries),
eventNodes: projectedEventNodes,
contexts: projectedContexts,
interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes),
partial: transient.partial,
runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls),
}
}

View File

@@ -174,6 +174,8 @@ export interface ToolResultNode {
callView: ToolCallView | null
/** Host-computed render intent from this tool/result's wire view; null = same default. */
resultView: ToolResultView | null
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
}
/**
@@ -263,21 +265,6 @@ export type ConversationNode =
| CompactionSummaryNode
| UnknownSurfaceNode
/**
* One `run_code` sub-dispatch materialized in the native call-block shapes so
* every consumer (tool rows, details panel) renders it through the exact
* components that render a native call: a started-but-unsettled sub-call is a
* {@link RunningToolCall} (rows derive the running state from the shape,
* exactly as for native calls) and its `tool/code-dispatch` settlement
* replaces it in place with the {@link ToolResultNode} form. Never part of
* the transcript `nodes` flow — sub-calls live under their parent via
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
* and its JSON-stringified logged arguments; `content`/`isError` are the
* settled sub-call's complete logged outcome.
*/
export type CodeSubCall = RunningToolCall | ToolResultNode
/** In-flight tool card material: tool/call seen, tool/result not yet. */
export interface RunningToolCall {
callId: string
@@ -289,8 +276,12 @@ export interface RunningToolCall {
time: number
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
callView: ToolCallView | null
/** Child calls owned by this call, in dispatch order. */
subCalls: readonly ToolCallBlock[]
}
/** One running or settled call, recursively owning its child calls. */
export type ToolCallBlock = RunningToolCall | ToolResultNode
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
export interface QueuedMessage {
@@ -355,13 +346,6 @@ export interface ConversationSnapshot {
turnEnds: ReadonlyMap<number, number>
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[]
/** Authoritative transient inbox snapshot, including queued and steering placements. */
queue: readonly QueuedMessage[]

View File

@@ -1,7 +1,7 @@
import type { ToolSchema } from '@deepseek-ai/dsh-llm/types'
import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client'
import type {
CodeSubCall, ConversationNode, PartialAssistant, RunningToolCall,
ConversationNode, PartialAssistant, RunningToolCall,
} from './conversation.ts'
import type { ConversationContext } from './conversation-context.ts'
import { projectConversationHistory } from '../session-history/history-fold.ts'
@@ -34,7 +34,6 @@ export interface SessionHistoryInspection {
interruptedNodes: readonly ConversationNode[]
partial: PartialAssistant | null
runningCalls: readonly RunningToolCall[]
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}
/**
@@ -112,9 +111,6 @@ export function createHistoryInspection(
get runningCalls() {
return conversationProjection().runningCalls
},
get codeDispatches() {
return conversationProjection().codeDispatches
},
get requests() {
return requestProjection().requests
},

View File

@@ -13,7 +13,7 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionFace } from '../contract/session.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
@@ -24,6 +24,7 @@ import { Notifier } from './notifier.ts'
import { isVisibleAssistantChunk, PartialAccumulator } from './partial.ts'
import { ProjectionValueStore } from './projection-store.ts'
import type { ProjectionsBaseline } from './projection-store.ts'
import { ToolCallTree } from './tool-call-tree.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
@@ -129,11 +130,8 @@ export class Session implements SessionFace {
private queued: QueuedMessage[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | 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
/** Window-derived child-call lifecycle and immutable tree projection. */
private readonly toolCallTree = new ToolCallTree()
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
@@ -746,65 +744,10 @@ export class Session implements SessionFace {
this.derivedRev++
return
}
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair 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 them structurally — the same posture as every
// other cross-wire event payload.
if ((event.type as string) === 'tool/code-dispatch-start') {
// A started sub-dispatch enters the index as a RunningToolCall — the
// exact shape a native in-flight call renders from — under its parent
// run_code callId; it never joins the surface flow.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const running: CodeSubCall = {
callId: data.subCallId, name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0, step: 0, time: event.time, callView: null,
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
this.codeDispatches.set(data.parentCallId, [...siblings, running])
this.dispatchesRev++
return
}
if ((event.type as string) === 'tool/code-dispatch') {
// Settlement replaces the running entry in place (same array position,
// so parallel sub-calls keep their start order) with the
// ToolResultNode form; a settle with no observed start (history window
// cut mid-pair, or a pre-start-event log) appends directly.
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
const settled: CodeSubCall = {
kind: 'tool-result', seq: event.seq, time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
// Duration source: the paired start's time when observed; null =
// unknown (settle-only window), matching the native tool-result
// contract so views never present a fabricated zero duration.
callTime: started === undefined ? null : started.time,
content: data.content, isError: data.isError,
callView: null, resultView: null,
}
this.codeDispatches.set(
data.parentCallId,
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
)
this.dispatchesRev++
return
}
// These lifecycle events are declared by a host-only plugin whose Context
// types cannot enter the client program. ToolCallTree owns their structural
// wire narrowing, pairing, and nested snapshot projection.
if (this.toolCallTree.apply(event)) return
switch (event.type) {
case 'turn/start':
this.lastStepByTurn.set(event.data.turn, 0)
@@ -834,6 +777,7 @@ export class Session implements SessionFace {
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
turn: event.data.turn, step: event.data.step, time: event.time,
callView: view?.for === 'call' ? view.view : null,
subCalls: [],
})
this.callsRev++
return
@@ -901,7 +845,7 @@ export class Session implements SessionFace {
call: { name: call.name, argsRaw: call.argsRaw },
callTime: call.time,
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null,
callView: call.callView, resultView: null, subCalls: [],
})
this.derivedRev++
}
@@ -948,8 +892,7 @@ export class Session implements SessionFace {
this.turnTimingsRev++
this.turnEnds = new Map()
this.turnEndsRev++
this.codeDispatches = new Map()
this.dispatchesRev++
this.toolCallTree.reset()
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. */
@@ -988,22 +931,18 @@ export class Session implements SessionFace {
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) }
}
if (this.queueCache === null || this.queueCache.rev !== this.queueRev) {
this.queueCache = { rev: this.queueRev, value: this.queued }
}
const partial = this.partial?.toPartial() ?? null
return {
sessionId: this.sessionId,
nodes,
nodes: this.toolCallTree.projectNodes(nodes),
turnTimings: this.turnTimingsCache.value,
turnEnds: this.turnEndsCache.value,
partial,
runningCalls: this.callsCache.value,
runningCalls: this.toolCallTree.projectRunningCalls(this.callsCache.value),
pending: this.pendingCache.value,
codeDispatches: this.dispatchesCache.value,
queue: this.queueCache.value,
running: this.running,
subagent: this.address === undefined

View File

@@ -0,0 +1,171 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode,
} from './conversation.ts'
interface ProjectedBlock {
source: ToolCallBlock
children: readonly ToolCallBlock[]
value: ToolCallBlock
}
function sameBlocks(
left: readonly ToolCallBlock[],
right: readonly ToolCallBlock[],
): boolean {
return left.length === right.length
&& left.every((block, index) => block === right[index])
}
/**
* Owns Code Dispatch pairing and projects its private parent index into the
* recursive Tool call contract exposed by conversation snapshots.
*/
export class ToolCallTree {
private readonly childrenByParent = new Map<string, readonly ToolCallBlock[]>()
private readonly projectedByCall = new Map<string, ProjectedBlock>()
private revision = 0
private nodesCache: {
source: readonly ConversationNode[]
revision: number
value: readonly ConversationNode[]
} | null = null
private runningCache: {
source: readonly RunningToolCall[]
revision: number
value: readonly RunningToolCall[]
} | null = null
/** Forget all event-derived child calls before replaying a new window. */
reset(): void {
this.childrenByParent.clear()
this.projectedByCall.clear()
this.revision++
}
/**
* Fold one event when it belongs to the Code Dispatch lifecycle.
* @param event - Session event from the current live or history window.
* @returns Whether the event was consumed as a child-call lifecycle event.
*/
apply(event: SessionEvent): boolean {
if ((event.type as string) === 'tool/code-dispatch-start') {
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
}
const running: RunningToolCall = {
callId: data.subCallId,
name: data.name,
argsRaw: JSON.stringify(data.arguments),
turn: 0,
step: 0,
time: event.time,
callView: null,
subCalls: [],
}
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
this.childrenByParent.set(data.parentCallId, [...siblings, running])
this.revision++
return true
}
if ((event.type as string) !== 'tool/code-dispatch') return false
const data = event.data as unknown as {
parentCallId: string
subCallId: string
name: string
arguments: unknown
isError: boolean
content: ContentBlock[]
}
const siblings = this.childrenByParent.get(data.parentCallId) ?? []
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
const started = at === -1 ? undefined : siblings[at]
const settled: ToolResultNode = {
kind: 'tool-result',
seq: event.seq,
time: event.time,
callId: data.subCallId,
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
callTime: started?.time ?? null,
content: data.content,
isError: data.isError,
callView: null,
resultView: null,
subCalls: [],
}
this.childrenByParent.set(
data.parentCallId,
at === -1
? [...siblings, settled]
: siblings.map((sub, index) => index === at ? settled : sub),
)
this.revision++
return true
}
/**
* Attach recursively projected children to all settled roots in a node list.
* @param nodes - Cache-stable base conversation nodes.
* @returns The original list when no root changed, otherwise a structurally shared list.
*/
projectNodes(nodes: readonly ConversationNode[]): readonly ConversationNode[] {
if (this.nodesCache?.source === nodes && this.nodesCache.revision === this.revision) {
return this.nodesCache.value
}
let changed = false
const projected = nodes.map((node): ConversationNode => {
if (node.kind !== 'tool-result') return node
const value = this.projectBlock(node) as ToolResultNode
changed ||= value !== node
return value
})
const value = changed ? projected : nodes
this.nodesCache = { source: nodes, revision: this.revision, value }
return value
}
/**
* Attach recursively projected children to all running root calls.
* @param calls - Cache-stable base running calls.
* @returns The original list when no root changed, otherwise a structurally shared list.
*/
projectRunningCalls(calls: readonly RunningToolCall[]): readonly RunningToolCall[] {
if (this.runningCache?.source === calls && this.runningCache.revision === this.revision) {
return this.runningCache.value
}
let changed = false
const projected = calls.map((call): RunningToolCall => {
const value = this.projectBlock(call) as RunningToolCall
changed ||= value !== call
return value
})
const value = changed ? projected : calls
this.runningCache = { source: calls, revision: this.revision, value }
return value
}
private projectBlock(block: ToolCallBlock): ToolCallBlock {
const children = this.childrenByParent.get(block.callId) ?? block.subCalls
const projectedChildren = children.map(child => this.projectBlock(child))
const childValue = sameBlocks(children, projectedChildren)
? children
: projectedChildren
const cached = this.projectedByCall.get(block.callId)
if (cached?.source === block && sameBlocks(cached.children, childValue)) {
return cached.value
}
const value: ToolCallBlock = block.subCalls === childValue
? block
: { ...block, subCalls: childValue }
this.projectedByCall.set(block.callId, {
source: block,
children: childValue,
value,
})
return value
}
}

View File

@@ -103,6 +103,7 @@ function materializeNode(
meta: event.data.meta,
callView: call?.callView ?? null,
resultView,
subCalls: [],
}
}
/* v8 ignore next 2 -- defensive arm: only the four surface-eligible types

View File

@@ -1151,7 +1151,17 @@ describe('resync', () => {
})
describe('run_code sub-dispatch indexing', () => {
describe('nested run_code sub-dispatches', () => {
const subCallsOf = (session: Session, callId: string) => {
const snapshot = session.getSnapshot()
const running = snapshot.runningCalls.find(call => call.callId === callId)
if (running !== undefined) return running.subCalls
for (const node of snapshot.nodes) {
if (node.kind === 'tool-result' && node.callId === callId) return node.subCalls
}
return undefined
}
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
@@ -1161,19 +1171,19 @@ describe('run_code sub-dispatch indexing', () => {
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
const live = session.getSnapshot().codeDispatches.get('p1')
const live = subCallsOf(session, 'p1')
expect(live).toHaveLength(2)
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
const mixed = session.getSnapshot().codeDispatches.get('p1')
const mixed = subCallsOf(session, 'p1')
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
// The settle carries the paired start's time as callTime (duration source).
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
const settled = session.getSnapshot().codeDispatches.get('p1')
const settled = subCallsOf(session, 'p1')
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
})
@@ -1187,7 +1197,7 @@ describe('run_code sub-dispatch indexing', () => {
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')
const subs = subCallsOf(session, 'p1')
expect(subs).toHaveLength(2)
expect(subs?.[0]).toMatchObject({
kind: 'tool-result', callId: 'p1:code:1',
@@ -1205,23 +1215,29 @@ describe('run_code sub-dispatch indexing', () => {
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 () => {
it('rebuilds the same nested tree 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),
ev.codeDispatchStart(8, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }),
ev.codeDispatch(9, 'p1:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'),
ev.codeDispatch(10, 'p1', 1, 'run_code', { code: 'return tools.read({ path: "a.txt" })' }, 'alpha'),
ev.toolResult(11, 1, 'p1', '{"done":true}'),
ev.turnEnd(12, 1),
])
await session.open()
const subs = session.getSnapshot().codeDispatches.get('p1')
const subs = subCallsOf(session, 'p1')
expect(subs).toHaveLength(1)
expect(subs?.[0]).toMatchObject({ callId: 'p1:code:1', call: { name: 'bash' } })
expect(subs?.[0]).toMatchObject({
callId: 'p1:code:1',
call: { name: 'run_code' },
subCalls: [{ callId: 'p1:code:1:code:1', call: { name: 'read' } }],
})
})
it('keeps the dispatch map reference across unrelated changes and swaps it on a new dispatch', async () => {
it('keeps an unaffected root reference and path-copies it on a new child', async () => {
const { api, session } = makeSession()
api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定'))
await session.open()
@@ -1230,13 +1246,48 @@ describe('run_code sub-dispatch indexing', () => {
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()
const beforeRoot = before.runningCalls.find(call => call.callId === 'p1')!
feed(ev.chunkStart(9, 1))
feed(ev.chunkText(10, 1, '流式'))
const after = session.getSnapshot()
expect(after.codeDispatches).toBe(before.codeDispatches)
const afterRoot = after.runningCalls.find(call => call.callId === 'p1')!
expect(afterRoot).toBe(beforeRoot)
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)
const changedRoot = session.getSnapshot().runningCalls.find(call => call.callId === 'p1')!
expect(changedRoot).not.toBe(afterRoot)
expect(changedRoot.subCalls[0]).toBe(afterRoot.subCalls[0])
expect(changedRoot.subCalls).toHaveLength(2)
})
it('path-copies only the owning branch when a nested child changes', 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":"first"}'))
feed(ev.toolCall(8, 1, 'p2', 'run_code', '{"code":"2","description":"second"}'))
feed(ev.codeDispatch(9, 'p1', 1, 'run_code', { code: 'nested' }, 'child'))
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'sibling' }, 'sibling'))
feed(ev.codeDispatch(11, 'p2', 1, 'bash', { command: 'pwd' }, 'root two'))
const before = session.getSnapshot()
const beforeFirst = before.runningCalls.find(call => call.callId === 'p1')!
const beforeSecond = before.runningCalls.find(call => call.callId === 'p2')!
const beforeChild = beforeFirst.subCalls[0]!
const beforeSibling = beforeFirst.subCalls[1]!
feed(ev.codeDispatch(12, 'p1:code:1', 1, 'read', { path: 'nested' }, 'leaf'))
const after = session.getSnapshot()
const afterFirst = after.runningCalls.find(call => call.callId === 'p1')!
const afterSecond = after.runningCalls.find(call => call.callId === 'p2')!
expect(afterFirst).not.toBe(beforeFirst)
expect(afterSecond).toBe(beforeSecond)
expect(afterFirst.subCalls[0]).not.toBe(beforeChild)
expect(afterFirst.subCalls[1]).toBe(beforeSibling)
expect(afterFirst.subCalls[0]?.subCalls).toMatchObject([
{ callId: 'p1:code:1:code:1', call: { name: 'read' } },
])
})
})

View File

@@ -50,7 +50,6 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
turnEnds: new Map(),
partial: null,
runningCalls: [],
codeDispatches: new Map(),
pending: [],
queue: [],
running: false,

View File

@@ -24,7 +24,7 @@ import {
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -111,6 +111,11 @@ type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
function treeContainsCall(block: ToolCallBlock, callId: string | undefined): boolean {
return callId !== undefined
&& (block.callId === callId || block.subCalls.some(child => treeContainsCall(child, callId)))
}
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
if (!running) return null
for (let index = nodes.length - 1; index >= 0; index -= 1) {
@@ -174,7 +179,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
toolName={node.call?.name ?? ''}
block={node}
openFile={openFile}
selectedCallId={selectedCallId}
selectedCallId={treeContainsCall(node, selectedCallId) ? selectedCallId : undefined}
cwd={cwd}
inspectCall={inspectCall}
/>
@@ -595,7 +600,7 @@ export function ChatView({
toolName={call.name}
block={call}
openFile={openFile}
selectedCallId={selectedCallId}
selectedCallId={treeContainsCall(call, selectedCallId) ? selectedCallId : undefined}
cwd={cwd}
inspectCall={inspectCall}
/>

View File

@@ -40,19 +40,27 @@ function runningMaterial(call: RunningToolCall): CallMaterial {
return { name: call.name, argsRaw: call.argsRaw, block: call }
}
function findCall(block: ToolCallBlock, callId: string): ToolCallBlock | undefined {
if (block.callId === callId) return block
for (const child of block.subCalls) {
const found = findCall(child, callId)
if (found !== undefined) return found
}
return undefined
}
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
for (const node of s.nodes) {
if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
if (node.kind !== 'tool-result') continue
const found = findCall(node, callId)
if (found !== undefined) {
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
}
}
const open = s.runningCalls.find(c => c.callId === callId)
if (open !== undefined) return runningMaterial(open)
// run_code sub-dispatches: the native call-block shapes, so a selected
// sub-row resolves through the same material as a native call — the
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
for (const subs of s.codeDispatches.values()) {
for (const sub of subs) {
if (sub.callId !== callId) continue
return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
for (const root of s.runningCalls) {
const found = findCall(root, callId)
if (found !== undefined) {
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
}
}
return null

View File

@@ -42,7 +42,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -86,7 +86,7 @@ describe('deriveStats', () => {
it('ignores tool results with no call time', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
isError: false, callView: null, resultView: null,
isError: false, callView: null, resultView: null, subCalls: [],
}
const stats = deriveStats([tool, assistant(1, 1)])
expect(stats.steps).toBe(1)
@@ -104,7 +104,7 @@ describe('deriveStats', () => {
}
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
isError: false, callView: null, resultView: null,
isError: false, callView: null, resultView: null, subCalls: [],
}
const stats = deriveStats([timed, untimed, tool])
expect(stats.llmMs).toBe(2_500)

View File

@@ -37,7 +37,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -88,10 +88,10 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, subCalls: [],
})
const command = (over: Partial<CommandNode> = {}): CommandNode => ({
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],

View File

@@ -47,7 +47,7 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -126,18 +126,28 @@ describe('render branch tails', () => {
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => {
it('DetailsPanel resolves a nested run_code leaf to its full logged args and output', () => {
localStorage.clear()
const snap = snapshotBase()
const longText = 'x'.repeat(1_000)
snap.codeDispatches = new Map([['p1', [{
snap.runningCalls = [{
callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
time: 7_000, callView: null, subCalls: [{
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
callTime: 8_000,
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
}]]])
content: [], isError: false, callView: null, resultView: null,
subCalls: [{
kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
callTime: 8_500,
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
subCalls: [],
}],
}],
}]
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
@@ -168,7 +178,7 @@ describe('render branch tails', () => {
expect(view.getByTestId('tool-details-seat')).toBeTruthy()
expect(owners).toHaveLength(1)
expect(owners[0]?.block).toMatchObject({
callId: 'p1:code:1',
callId: 'p1:code:1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
content: [{ type: 'text', text: longText }],
})

View File

@@ -35,7 +35,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}

View File

@@ -70,7 +70,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -37,7 +37,7 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
...toolResult(seq, callId, 'write'),

View File

@@ -28,13 +28,14 @@ function settled(over: Partial<ToolResultNode> = {}): ToolResultNode {
isError: false,
callView: null,
resultView: null,
subCalls: [],
...over,
}
}
function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall {
return {
callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null,
callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, callView: null, subCalls: [],
}
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
README.md: bf6213ebfacd8f7963463b2c443524c631c28bcd
README.zh.md: 06a46a525eead005375bcf67794a1ceecde678bc
README.md: bab3f92e4f6780b041cee2e682cb8ece3386ef15
README.zh.md: 9187510fa354ba072835271d4b1153c8ee1105c0

View File

@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
Client Tool presentation plugin. `ui-conversation` supplies one ordered root call through `conversation.chat.tool`; this package renders that root and its Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card.
Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and `codeDispatches`; the conversation view remains authoritative for ChatFlow placement.
Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and recursive `subCalls` projection; the conversation view remains authoritative for ChatFlow placement.
## Rendering contract
`ToolCallTree` receives one root `ToolCallBlock`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. Through its standard session slot props it selects the Runtime-projected `codeDispatches[rootCallId]` array, then sends the root and every child through the same atomic dispatch path. The Runtime currently exposes only one Code Dispatch child level, so the renderer preserves that shape instead of inventing recursive data.
`ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map.
Each root and child wrapper preserves the `conversation.chat.tool` call-anchor DOM contract used for paging and selection.
@@ -44,6 +44,5 @@ None. The package is client-only presentation.
## Known Limitations and Deferred Work
- The Runtime currently exposes one level of Code Dispatch children. The renderer sends roots and children through the same atomic path, but it does not claim an arbitrary recursive wire topology.
- Existing first-party Tool views are initially colocated here and can move to their owning business packages independently through the keyed slot.
- Tool copy temporarily reuses the `ui-conversation` locale namespace.

View File

@@ -4,11 +4,11 @@
Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交付一个已经排好位置的 root call本包渲染该 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。
业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和 `codeDispatches`conversation view 继续负责 ChatFlow 位置。
业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和递归 `subCalls` 投影conversation view 继续负责 ChatFlow 位置。
## 渲染契约
`ToolCallTree` 接收一个 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,再让 root 与每个 child 经过同一条原子分发路径。Runtime 当前只暴露一层 Code Dispatch child因此 renderer 保留该形状,不自行发明递归数据
`ToolCallTree` 接收一个已经包含递归 `subCalls` root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它递归遍历标准 call block让 root 与任意深度的 child 经过同一条原子分发路径,不再订阅独立的 parent-to-children map
每个 root 和 child wrapper 都保留 `conversation.chat.tool` 的 call-anchor DOM 契约,供分页和 selection 使用。
@@ -44,6 +44,5 @@ owner 载荷为 `ToolCallOwnerProps``callId`、`toolName`、冻结的 `block`
## 已知限制与后续工作
- Runtime 当前只暴露一层 Code Dispatch 子调用。renderer 会让 root 和 child 经过同一个原子分发路径,但不宣称 wire 拓扑已经支持任意递归。
- 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。
- Tool 文案暂时复用 `ui-conversation` locale namespace。

View File

@@ -1,12 +1,12 @@
/** Root/subcall Tool composition with one keyed atomic dispatch path. */
import { memo, useMemo, type ReactNode } from 'react'
import type { CodeSubCall, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts'
import { GenericToolCard } from './toolviews/GenericToolCard.tsx'
import css from './ToolCallTree.module.css'
/** Resolve a Code Dispatch child's wire Tool name from either lifecycle form. */
function subCallName(node: CodeSubCall): string {
/** Resolve a Tool call's wire name from either lifecycle form. */
function callName(node: ToolCallBlock): string {
return 'kind' in node ? node.call?.name ?? '' : node.name
}
@@ -44,40 +44,33 @@ const ToolCall = memo(function ToolCall({
)
})
/**
* Render one root Tool call and its currently supported one-level Code
* Dispatch children. Root and children use the same atomic keyed dispatch.
* @param props - whole-Tool owner data and the Tool-owned child-slot share.
* @returns the Tool call tree.
*/
export function ToolCallTree({
useSession, renderSlot, callId, toolName, block, selectedCallId, cwd, openFile, inspectCall, t,
}: ToolTreeProps) {
const subCalls = useSession(snapshot => snapshot.codeDispatches.get(callId))
const ToolCallBranch = memo(function ToolCallBranch({
renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t,
}: Pick<ToolTreeProps, 'renderSlot' | 'selectedCallId' | 'cwd' | 'openFile' | 'inspectCall' | 't'> & {
block: ToolCallBlock
}) {
return (
<ToolCall
renderSlot={renderSlot}
callId={callId}
toolName={toolName}
callId={block.callId}
toolName={callName(block)}
block={block}
openFile={openFile}
selected={callId === selectedCallId}
selected={block.callId === selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
t={t}
>
{subCalls !== undefined && subCalls.length > 0 ? (
{block.subCalls.length > 0 ? (
<div className={css.subCalls} data-subcalls>
{subCalls.map(node => (
<ToolCall
key={node.callId}
{block.subCalls.map(child => (
<ToolCallBranch
key={child.callId}
renderSlot={renderSlot}
callId={node.callId}
toolName={subCallName(node)}
block={node}
openFile={openFile}
selected={node.callId === selectedCallId}
block={child}
selectedCallId={selectedCallId}
cwd={cwd}
openFile={openFile}
inspectCall={inspectCall}
t={t}
/>
@@ -86,4 +79,26 @@ export function ToolCallTree({
) : null}
</ToolCall>
)
})
/**
* Render one root Tool call and its recursive children through the same
* atomic keyed dispatch.
* @param props - whole-Tool owner data and the Tool-owned child-slot share.
* @returns the Tool call tree.
*/
export function ToolCallTree({
renderSlot, block, selectedCallId, cwd, openFile, inspectCall, t,
}: ToolTreeProps) {
return (
<ToolCallBranch
renderSlot={renderSlot}
block={block}
selectedCallId={selectedCallId}
cwd={cwd}
openFile={openFile}
inspectCall={inspectCall}
t={t}
/>
)
}

View File

@@ -24,11 +24,11 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<T
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'ask_user_question', argsRaw },
content: resultText === null ? [] : [{ type: 'text', text: resultText }],
isError: false, callView: null, resultView: null, ...over,
isError: false, callView: null, resultView: null, subCalls: [], ...over,
})
const runningCall = (argsRaw: string) =>
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })
// Standard locale seat stub mirroring the real ns → common → key chain.
const t = makeTranslate(zh, commonZh)

View File

@@ -40,7 +40,7 @@ const todoResult = (seq: number): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -50,6 +50,7 @@ const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>)
content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
subCalls: [],
...over,
})

View File

@@ -13,8 +13,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CodeSubCall, ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
ToolResultNode, WorkspaceListState,
ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
ToolCallBlock, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
@@ -48,27 +48,33 @@ const codeResult = (seq: number, callId: string): ToolResultNode => ({
call: { name: 'run_code', argsRaw: RUN_CODE_ARGS },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null,
subCalls: [],
})
const runningCode = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null,
subCalls: [],
})
const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): CodeSubCall => ({
const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): ToolCallBlock => ({
kind: 'tool-result', seq, time: seq * 1_000,
callId: `${parent}:code:${n}`,
call: { name, argsRaw: JSON.stringify(args) },
callTime: seq * 1_000,
content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null,
subCalls: [],
})
function snapshotWith(
nodes: ToolResultNode[],
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>,
subCalls: readonly ToolCallBlock[],
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
const nestedNodes = nodes.map(node => ({ ...node, subCalls }))
const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls }))
return {
sessionId: SID, nodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
sessionId: SID, nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null,
runningCalls: nestedRunningCalls,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
@@ -165,11 +171,11 @@ function mountApp(slots: SlotsService) {
describe('run_code sub-calls through the real chat machinery', () => {
it('renders the code-variant parent row with the description summary and nested sub-rows', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
const subCalls = [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
]
const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots)
// Parent row: the code variant with the model-authored description.
@@ -193,12 +199,12 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('renders Cordis sub-calls with lifecycle titles over the generic variants', async () => {
const parent = 'call-cordis'
const code = 'return { name: "audit", apply(ctx) {} }'
const dispatches = new Map([[parent, [
const subCalls = [
subCall(11, parent, 1, 'cordis_inspect', { what: 'temporary' }, '## Temporary Plugins'),
subCall(12, parent, 2, 'cordis_mount', { code }, 'Temporary Plugin dyn-2 is running'),
subCall(13, parent, 3, 'cordis_unmount', { id: 'dyn-2' }, 'Temporary Plugin dyn-2 was unmounted and removed.'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
]
const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots)
const nest = view.container.querySelector('[data-subcalls]')!
@@ -214,7 +220,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
const b = await bench(snapshotWith([codeResult(10, parent)], []))
const view = mountApp(b.slots)
// The code row is expandable via the whole summary row (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]')
@@ -230,10 +236,10 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
const subCalls = [
subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
]
const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots)
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]')
expect(nested).not.toBeNull()
@@ -241,11 +247,11 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
const subCalls = [
subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'),
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
]
const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
const view = mountApp(b.slots)
view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
@@ -258,10 +264,10 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
const parent = 'call-live'
const dispatches = new Map([[parent, [
const subCalls = [
subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
]
const b = await bench(snapshotWith([], subCalls, [runningCode(parent)]))
const view = mountApp(b.slots)
const running = view.container.querySelector('[data-variant="code"][data-state="running"]')
expect(running).not.toBeNull()
@@ -272,12 +278,11 @@ describe('run_code sub-calls through the real chat machinery', () => {
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
const parent = 'call-live'
const runningSub: CodeSubCall = {
const runningSub: ToolCallBlock = {
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
turn: 0, step: 0, time: 21_000, callView: null,
turn: 0, step: 0, time: 21_000, callView: null, subCalls: [],
}
const dispatches = new Map([[parent, [runningSub]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)]))
const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the
// same data-state chrome (row sweep) a native in-flight row wears.
@@ -291,9 +296,9 @@ describe('run_code sub-calls through the real chat machinery', () => {
kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
call: { name: 'mystery', argsRaw: '{"n":1}' },
callTime: 9_500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
}
const b = await bench(snapshotWith([plain], new Map()))
const b = await bench(snapshotWith([plain], []))
const view = mountApp(b.slots)
expect(view.container.querySelector('[data-subcalls]')).toBeNull()
})

View File

@@ -57,7 +57,7 @@ describe('Tool presentation tails', () => {
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
callTime: 1_000,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
}
const props: GenericToolCardProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
@@ -72,7 +72,7 @@ describe('Tool presentation tails', () => {
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
callTime: 2_000,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
}
const view = render(<BashRow {...bashProps(settled)} />)
const row = view.container.querySelector('[data-sample="bash"]')!
@@ -84,13 +84,13 @@ describe('Tool presentation tails', () => {
it('BashRow carries data-state for running and StateDots for error/stopped', () => {
const running: RunningToolCall = {
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
turn: 1, step: 1, time: 1_000, callView: null,
turn: 1, step: 1, time: 1_000, callView: null, subCalls: [],
}
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
content: [], isError: true, callView: null, resultView: null,
content: [], isError: true, callView: null, resultView: null, subCalls: [],
}
const stoppedResult: ToolResultNode = {
...errorResult,

View File

@@ -48,7 +48,7 @@ const resultDiff = (over?: Partial<Extract<ToolResultView, { card: 'diff' }>>):
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'edit', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over,
turn: 1, step: 1, time: 1_000, callView: callDiff(), subCalls: [], ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -56,7 +56,7 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'edit', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false,
callView: callDiff(), resultView: resultDiff(), ...over,
callView: callDiff(), resultView: resultDiff(), subCalls: [], ...over,
})
describe('diffCardModel', () => {
@@ -343,7 +343,7 @@ describe('DetailsPanel diff Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -59,7 +59,7 @@ const resultRead = (over?: Partial<Extract<ToolResultView, { card: 'read' }>>):
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'read', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, subCalls: [], ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -67,7 +67,7 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'read', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over,
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), subCalls: [], ...over,
})
describe('readCardModel', () => {
@@ -289,7 +289,7 @@ describe('DetailsPanel Output section (read)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -65,7 +65,7 @@ const resultPaths = (over?: Partial<Extract<ToolResultView, { card: 'search'; sh
const runningGrep = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'grep', argsRaw: GREP_ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, ...over,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, subCalls: [], ...over,
})
const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -73,7 +73,7 @@ const settledGrep = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'grep', argsRaw: GREP_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts\n Line 12: const foo = 1' }], isError: false,
callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), ...over,
callView: { card: 'generic', title: 'Grep foo', kind: 'search' }, resultView: resultMatches(), subCalls: [], ...over,
})
const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -81,7 +81,7 @@ const settledGlob = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'glob', argsRaw: GLOB_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'src/a.ts\nsrc/b.ts' }], isError: false,
callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), ...over,
callView: { card: 'generic', title: 'Glob **/*.ts', kind: 'search' }, resultView: resultPaths(), subCalls: [], ...over,
})
describe('searchCardModel', () => {
@@ -405,7 +405,7 @@ describe('DetailsPanel Output section (search)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -58,7 +58,7 @@ const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over,
turn: 1, step: 1, time: 1_000, callView: callTerminal(), subCalls: [], ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -66,7 +66,7 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'bash', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'a.ts b.ts\nc.ts d.ts\n' }], isError: false,
callView: callTerminal(), resultView: resultTerminal(), ...over,
callView: callTerminal(), resultView: resultTerminal(), subCalls: [], ...over,
})
describe('terminalCardModel', () => {
@@ -480,7 +480,7 @@ describe('DetailsPanel Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,
@@ -568,15 +568,17 @@ describe('DetailsPanel Output section', () => {
// pins the resolution path with views injected directly, and the arm below
// pins what the shipped path actually shows today.
it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
const child = settled({ callId: 'c1' })
const view = mount(snapshot({
codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
runningCalls: [running({ callId: 'p1', subCalls: [child] })],
}), target)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
})
it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
const child = settled({ callId: 'c1', callView: null, resultView: null })
const view = mount(snapshot({
codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]),
runningCalls: [running({ callId: 'p1', subCalls: [child] })],
}), target)
// No terminal card: the generic path renders the result text in the Output
// section's <pre> (the Input section has its own, hence the scoping).
@@ -588,7 +590,10 @@ describe('DetailsPanel Output section', () => {
it('a running run_code sub-dispatch resolves through the running material', () => {
const view = mount(snapshot({
// The leading non-matching sub-call exercises the scan's skip.
codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
runningCalls: [running({
callId: 'p1',
subCalls: [running({ callId: 'other' }), running()],
})],
}), target)
expect(view.getByText('ls -la')).toBeTruthy()
})

View File

@@ -61,7 +61,7 @@ describe('planSummary', () => {
const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'todo_write', argsRaw },
content: [], isError: false, callView: null, resultView: null, ...over,
content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
})
function rowProps(block: unknown): TodoRowProps {
@@ -106,7 +106,7 @@ describe('TodoRow', () => {
it('keeps non-ok execution states visible through the shared row states', () => {
const args = JSON.stringify({ todos: LIST })
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null, subCalls: [] })} />)
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
running.unmount()

View File

@@ -2,7 +2,7 @@
/** ToolCallTree-owned root/subcall markers and selection projection. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import type { CodeSubCall, ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { ToolTreeProps } from '../src/client/contract/slots.ts'
@@ -15,15 +15,14 @@ const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh)
const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({
kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
function props(
block: ToolResultNode,
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]> = new Map(),
selectedCallId?: string,
): ToolTreeProps {
const snapshot = { codeDispatches } as ConversationSnapshot
const snapshot = {} as ConversationSnapshot
const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession']
const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) =>
options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot']
@@ -43,7 +42,7 @@ function props(
describe('ToolCallTree', () => {
it('owns the root marker, generic fallback, and selected state for a window-truncated call', () => {
const block = root('w1', null)
const view = render(<ToolCallTree {...props(block, new Map(), 'w1')} />)
const view = render(<ToolCallTree {...props(block, 'w1')} />)
const row = view.container.querySelector('[data-chat-call-id="w1"]')
expect(row?.getAttribute('data-chat-anchor-key')).toBe('call:w1')
expect(row?.getAttribute('data-selected')).toBe('true')
@@ -51,15 +50,23 @@ describe('ToolCallTree', () => {
expect(view.getByText('w1')).toBeTruthy()
})
it('marks a selected subcall without selecting its root', () => {
const block = root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' })
const child: CodeSubCall = root('parent:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
const view = render(
<ToolCallTree {...props(block, new Map([['parent', [child]]]), child.callId)} />,
)
expect(view.container.querySelector('[data-subcalls]')?.parentElement)
.toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
it('recursively renders a selected leaf without selecting its ancestors', () => {
const leaf = root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
const child = {
...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
subCalls: [leaf],
}
const block = {
...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
subCalls: [child],
}
const view = render(<ToolCallTree {...props(block, leaf.callId)} />)
const nests = view.container.querySelectorAll('[data-subcalls]')
expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]'))
expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false)
expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.getAttribute('data-selected')).toBe('true')
expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.hasAttribute('data-selected')).toBe(false)
expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true')
expect(nests).toHaveLength(2)
})
})

View File

@@ -20,14 +20,14 @@ const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
turn: 1, step: 1, time: 1_000, callView: null, ...over,
turn: 1, step: 1, time: 1_000, callView: null, subCalls: [], ...over,
})
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
callTime: 1_000,
content: [], isError: false, callView: null, resultView: null, ...over,
content: [], isError: false, callView: null, resultView: null, subCalls: [], ...over,
})
describe('tool-call-model', () => {

View File

@@ -42,7 +42,7 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: args },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
content: [], isError: false, callView: null, resultView: null, subCalls: [],
})
/** Test-owned AppFrame role: declares and renders the resident conversation area. */

View File

@@ -56,7 +56,7 @@ const resultFetch = (over?: Partial<Extract<ToolResultView, { card: 'web'; kind:
const runningSearch = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, ...over,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Search', kind: 'search' }, subCalls: [], ...over,
})
const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -64,7 +64,7 @@ const settledSearch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'web_search', argsRaw: SEARCH_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'search text' }], isError: false,
callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), ...over,
callView: { card: 'generic', title: 'Search', kind: 'search' }, resultView: resultSearch(), subCalls: [], ...over,
})
const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
@@ -72,7 +72,7 @@ const settledFetch = (over?: Partial<ToolResultNode>): ToolResultNode => ({
call: { name: 'web_fetch', argsRaw: FETCH_ARGS },
callTime: 1_000,
content: [{ type: 'text', text: 'fetch body' }], isError: false,
callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), ...over,
callView: { card: 'generic', title: 'Fetch', kind: 'fetch' }, resultView: resultFetch(), subCalls: [], ...over,
})
describe('webCardModel', () => {
@@ -235,7 +235,7 @@ describe('DetailsPanel web Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -211,7 +211,6 @@ export function TrajectoryView({
const nodes = inspection.eventNodes
const partial = inspection.partial
const runningCalls = inspection.runningCalls
const codeDispatches = inspection.codeDispatches
const loadHistoryTailRef = useRef(loadHistoryTail)
loadHistoryTailRef.current = loadHistoryTail
const historyControllerRef = useRef<AbortController | null>(null)
@@ -366,12 +365,11 @@ export function TrajectoryView({
runningCalls,
requests: selectedRequests,
callSchemas,
codeDispatches,
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas, codeDispatches,
runningCalls, selectedRequests, callSchemas,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null

View File

@@ -5,11 +5,11 @@
import type {
AssistantBlock,
AssistantMessageNode,
CodeSubCall,
ConversationSnapshot,
RequestInspectionSnapshot,
RequestPromptChange,
RequestView,
ToolCallBlock,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -39,8 +39,6 @@ export interface TrajectoryLayoutInput {
runningCalls: ConversationSnapshot['runningCalls']
requests?: readonly RequestView[]
callSchemas?: RequestInspectionSnapshot['callSchemas']
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
codeDispatches: ConversationSnapshot['codeDispatches']
}
interface UsageLike {
@@ -57,6 +55,7 @@ interface LaidCell {
absTime: number | null
toolName?: string
callId?: string
subCalls?: readonly ToolCallBlock[]
}
interface LaidGroup {
@@ -131,9 +130,11 @@ function inputCellDetail(node: InputNode): Pick<
*/
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
const {
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
nodes, partial, runningCalls, requests = [], callSchemas,
} = input
const resultByCall = indexResults(nodes)
const callById = new Map<string, ToolCallBlock>(resultByCall)
for (const call of runningCalls) callById.set(call.callId, call)
const emittedCallIds = indexAssistantCallIds(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
@@ -344,8 +345,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
}
if (node.kind === 'assistant') {
const laidList = withSubCalls(
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
codeDispatches,
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById),
)
if (node.step > 0) pushStep(node.turn, node.step, laidList)
else for (const laid of laidList) pushMessage(node.turn, laid)
@@ -381,6 +381,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}),
callId: node.callId,
subCalls: node.subCalls,
cell: {
index: ++index,
kind: 'tool',
@@ -398,7 +399,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
startedAt: finiteTime(node.callTime),
},
}]
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
for (const laid of expandSubCalls(node.subCalls, index)) {
laidList.push(laid)
index = laid.cell.index
}
@@ -413,14 +414,15 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
turn: partial.turn, step: partial.step, blocks: partial.blocks,
}
const laidList = expandAssistant(
const laidList = withSubCalls(expandAssistant(
fake,
index + 1,
prevAbsTime,
resultByCall,
callStartById,
callById,
{ streaming: true },
)
))
if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
else for (const laid of laidList) pushMessage(partial.turn, laid)
const last = laidList[laidList.length - 1]
@@ -434,6 +436,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
absTime: null,
toolName: call.name,
callId: call.callId,
subCalls: call.subCalls,
cell: {
index: ++index,
kind: 'tool',
@@ -444,7 +447,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
startedAt: finiteTime(call.time),
},
}]
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
for (const laid of expandSubCalls(call.subCalls, index)) {
laidList.push(laid)
index = laid.cell.index
}
@@ -491,7 +494,6 @@ export function appendTrajectoryPartialLayout(
nodes: [],
partial,
runningCalls: [],
codeDispatches: new Map(),
}).at(0)
if (partialTurn === undefined) return turns
const streamed: TrajectoryTurnModel = {
@@ -622,6 +624,7 @@ function expandAssistant(
prevAbsTime: number | null,
results: Map<string, ToolResultNode>,
callStarts: ReadonlyMap<string, number>,
calls: ReadonlyMap<string, ToolCallBlock>,
opts?: { streaming?: boolean },
): LaidCell[] {
if (opts?.streaming === true && node.blocks.length === 0) return []
@@ -677,10 +680,12 @@ function expandAssistant(
? null
: durationSeconds(result.time, result.callTime)
const callAbs = finiteTime(callStarts.get(block.callId))
const call = calls.get(block.callId)
out.push({
absTime: callAbs,
toolName: block.name,
callId: block.callId,
...(call === undefined ? {} : { subCalls: call.subCalls }),
cell: {
index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw),
@@ -883,15 +888,14 @@ function collectCallIds(
/** Interleave each tool cell's run_code sub-dispatch cells right after it, reindexing followers. */
function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot['codeDispatches']): LaidCell[] {
if (codeDispatches.size === 0) return laidList
/** Interleave each tool cell's nested child calls right after it, reindexing followers. */
function withSubCalls(laidList: LaidCell[]): LaidCell[] {
if (!laidList.some(laid => laid.subCalls !== undefined && laid.subCalls.length > 0)) return laidList
const out: LaidCell[] = []
let index = laidList[0] !== undefined ? laidList[0].cell.index - 1 : 0
for (const laid of laidList) {
out.push({ ...laid, cell: { ...laid.cell, index: ++index } })
if (laid.callId === undefined) continue
for (const sub of expandSubCalls(codeDispatches.get(laid.callId), index)) {
for (const sub of expandSubCalls(laid.subCalls, index)) {
out.push(sub)
index = sub.cell.index
}
@@ -901,7 +905,7 @@ function withSubCalls(laidList: LaidCell[], codeDispatches: ConversationSnapshot
/** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */
function expandSubCalls(
subs: readonly CodeSubCall[] | undefined,
subs: readonly ToolCallBlock[] | undefined,
startIndex: number,
): LaidCell[] {
if (subs === undefined || subs.length === 0) return []
@@ -909,10 +913,11 @@ function expandSubCalls(
let index = startIndex
for (const sub of subs) {
const settled = 'kind' in sub
out.push({
const laid: LaidCell = {
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
callId: sub.callId,
subCalls: sub.subCalls,
cell: {
index: ++index,
kind: 'subtool',
@@ -938,7 +943,12 @@ function expandSubCalls(
? finiteTime(sub.callTime)
: finiteTime(sub.time),
},
})
}
out.push(laid)
for (const child of expandSubCalls(sub.subCalls, index)) {
out.push(child)
index = child.cell.index
}
}
return out
}

View File

@@ -74,7 +74,7 @@ describe('deriveTrajectoryLayout', () => {
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns).toHaveLength(1)
expect(turns[0]?.turn).toBe(1)
const kinds = turns[0]?.groups.flatMap(g => g.cells.map(c => c.kind))
@@ -90,12 +90,11 @@ describe('deriveTrajectoryLayout', () => {
it('adds runningCalls not already present and leaves their time blank', () => {
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [],
partial: null,
runningCalls: [{
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 2, time: 9_000, callView: null,
turn: 1, step: 2, time: 9_000, callView: null, subCalls: [],
}],
})
expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2'])
@@ -119,7 +118,6 @@ describe('deriveTrajectoryLayout', () => {
startedAt: 3_000, completedAt: null, status: 'running',
} as unknown as RequestView
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: { ...partial, blocks: [] },
requests: [request],
@@ -152,12 +150,11 @@ describe('deriveTrajectoryLayout', () => {
}],
}
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [],
partial: { ...partial, blocks: [] },
runningCalls: [{
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 1, time: 9_000, callView: null,
turn: 1, step: 1, time: 9_000, callView: null, subCalls: [],
}],
})
@@ -180,7 +177,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
expect(cells.find(c => c.kind === 'message')?.timeSeconds).toBeNull()
expect(turns[0]?.groups.find(g => g.title === 'Step 1')?.description).toBeUndefined()
@@ -206,7 +203,7 @@ describe('deriveTrajectoryLayout', () => {
content: [], isError: false, callView: null, resultView: null,
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
})
@@ -223,7 +220,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'ok2' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
expect(turns.map(t => t.turn)).toEqual([1, 2])
expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1'])
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
@@ -254,7 +251,6 @@ describe('deriveTrajectoryLayout', () => {
}
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: null,
runningCalls: [],
@@ -280,7 +276,7 @@ describe('deriveTrajectoryLayout', () => {
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message')
expect(message).toMatchObject({
text: '…', input: 11, output: 22, think: 3,
@@ -295,7 +291,7 @@ describe('deriveTrajectoryLayout', () => {
}] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
@@ -333,7 +329,7 @@ describe('deriveTrajectoryLayout', () => {
blocks: [{ kind: 'text', text: 'done' }],
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
const cells = turns[0]?.groups.flatMap(g => g.cells) ?? []
const message = cells.find(c => c.kind === 'message' && c.text === 'done')
// From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces.
@@ -352,7 +348,7 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
@@ -372,6 +368,7 @@ describe('run_code sub-dispatch cells', () => {
kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1',
call: { name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, callTime: 6_200,
content: [{ type: 'text', text: 'done' }], isError: false, callView: null, resultView: null,
subCalls: [],
},
] as unknown as ConversationSnapshot['nodes']
@@ -380,14 +377,18 @@ describe('run_code sub-dispatch cells', () => {
callId: `p1:code:${n}`,
call: { name, argsRaw: '{"x":1}' }, callTime: start,
content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null,
subCalls: [],
})
const withSubCalls = (subCalls: readonly ReturnType<typeof settledSub>[] | readonly object[]) =>
runCodeNodes.map(node => node.kind === 'tool-result' ? { ...node, subCalls } : node) as ConversationSnapshot['nodes']
it('nests settled sub-cells after their parent Tool cell with real durations', () => {
const codeDispatches = new Map([['p1', [
const subCalls = [
settledSub(1, 'bash', 6_300, 7_300),
settledSub(2, 'read', 7_300, 7_800),
]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
]
const turns = deriveTrajectoryLayout({ nodes: withSubCalls(subCalls), partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap(g => g.cells)
expect(cells.map(c => c.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
expect(cells[0]?.text).toBe('Tool call only')
@@ -400,11 +401,29 @@ describe('run_code sub-dispatch cells', () => {
it('a running (unsettled) sub-call renders a subtool cell with blank time', () => {
const running = {
callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}',
turn: 0, step: 0, time: 6_400, callView: null,
turn: 0, step: 0, time: 6_400, callView: null, subCalls: [],
}
const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches']
const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, partial: null, runningCalls: [] })
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] })
const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool')
expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null })
})
it('recursively flattens nested child calls immediately after their parent', () => {
const leaf = {
...settledSub(2, 'read', 7_300, 7_800),
callId: 'p1:code:1:code:1',
}
const child = {
...settledSub(1, 'run_code', 6_300, 8_000),
subCalls: [leaf],
}
const turns = deriveTrajectoryLayout({ nodes: withSubCalls([child]), partial: null, runningCalls: [] })
const cells = turns[0]!.groups.flatMap(group => group.cells)
expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool', 'subtool', 'subtool'])
expect(cells.slice(2).map(cell => cell.callId)).toEqual([
'p1:code:1',
'p1:code:1:code:1',
])
expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4])
})
})

View File

@@ -82,7 +82,6 @@ function historySnapshot(
interruptedNodes: [],
partial: null,
runningCalls: [],
codeDispatches: new Map(),
...inspection,
},
}
@@ -115,7 +114,7 @@ function standaloneDuration(): Pick<
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
nodes, pending: [], partial: null,
runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
runningCalls: [] as ConversationSnapshot['runningCalls'],
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
@@ -186,7 +185,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
running: false, removed: false, promptError: null, nodes,
pending: [],
openState: 'open' as const, hasMore: true, loadingOlder: false,
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(),
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
const chat = createChatStore().create()