From e715d6cc596bb9b7d87edc201860b612189ba657 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:48:33 +0800 Subject: [PATCH 1/3] feat(web): Code Mode sub-calls in the trajectory and waterfall views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trajectory: the layout fold interleaves one subtool cell per sub-dispatch after its parent Tool cell (assistant-block calls, orphan results, and running calls alike), indexes sequential across the interleave; settled durations come from the start/settle pair, running sub-calls show the em dash. New Sub tag (business tint) + 28px indent. Waterfall: deriveSubSpans folds the dispatch index into per-turn lanes with REAL wall time — each parent's window is first start → last settle and every lane's offset/width is its fraction of it, so parallel sub-calls visibly overlap; running lanes extend to the window end at reduced opacity. Lanes draw under the owning turn row. Both views read codeDispatches through the standard snapshot hook; no new wire data, replay renders identically to live. Specs pin interleave order, durations, the running arms, window fractions, and the rendered lane. --- ...-mode-trajectory-waterfall-spans.i18n.yaml | 6 ++ ...26-code-mode-trajectory-waterfall-spans.md | 31 +++++++ ...code-mode-trajectory-waterfall-spans.zh.md | 31 +++++++ .../src/client/TrajectoryCell.module.css | 11 +++ .../src/client/TrajectoryCell.tsx | 7 +- .../src/client/TrajectoryView.tsx | 5 +- .../src/client/WaterfallView.tsx | 54 +++++++++---- .../client/ui-trajectory/src/client/layout.ts | 62 +++++++++++++- .../client/ui-trajectory/src/client/spans.ts | 65 +++++++++++++++ .../ui-trajectory/src/client/views.module.css | 29 +++++++ .../ui-trajectory/tests/layout.spec.tsx | 61 ++++++++++++-- .../client/ui-trajectory/tests/views.spec.tsx | 81 ++++++++++++++++++- 12 files changed, 412 insertions(+), 31 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md create mode 100644 .agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml new file mode 100644 index 0000000000..ac38a7465f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-26-code-mode-trajectory-waterfall-spans.md: 54449bcf8612a39461a769173d7f60c742f67ad8 +2026-07-26-code-mode-trajectory-waterfall-spans.zh.md: fbfb26c3a62554d60a6cb561ead78e10cd4115cd diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md new file mode 100644 index 0000000000..54449bcf86 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md @@ -0,0 +1,31 @@ +# Agent Note: Code Mode sub-calls in the trajectory and waterfall views + +Status: implemented + +English | [中文](2026-07-26-code-mode-trajectory-waterfall-spans.zh.md) + +> Scope: the final PR of the Code Mode UI stack — sub-dispatch rendering in the two non-chat views. Chat nesting is owned by the [sub-call rows note](2026-07-26-code-mode-chat-subcall-rows.md); the timing this consumes is the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md)'s start/settle pair. + +## Problem + +Trajectory and waterfall still rendered a `run_code` turn as one opaque Tool cell / one node-count bar. The chat view got nested sub-rows in the earlier PRs, but the two analytical views — whose whole purpose is structure and timing — showed none of the sub-call structure and none of the per-sub-call wall time the dispatch pair now records. Waterfall sub-spans were deliberately deferred until that pair existed: a span without real timing would have been a lie. + +## Decision + +**Trajectory: `subtool` cells interleaved after their parent Tool cell. Waterfall: real-time sub-lanes under the owning turn row.** + +- **Trajectory**: the layout fold takes the snapshot's `codeDispatches` index; after each Tool cell whose `callId` has dispatches (assistant-block calls, orphan results, and running calls alike), it interleaves one `subtool` cell per sub-dispatch in start order — indexes stay sequential across the interleave. A settled sub-call's duration is its start/settle pair (`durationSeconds(sub.time, sub.callTime)`); a running one shows the em dash, exactly the native in-flight convention. The new cell kind wears a `Sub` tag (business tint) and a 28px indent so nesting reads at a glance. +- **Waterfall**: `deriveSubSpans` folds the dispatch index into per-turn lanes with REAL timing — each parent's dispatch window is first start → last settle, and every lane's offset/width is its fraction of that window, so parallel sub-calls (PR3) visibly overlap. Running lanes extend to the window end at reduced opacity with a null duration. Lanes draw under the owning turn's bar row, scaled into a fixed lane budget. +- Both views read `codeDispatches` through the standard snapshot hook — no new wire data, no new stores; replay renders identically to live by construction. + +## Alternatives considered + +**Fold sub-calls into the turn-span node counts (weight the existing bars).** Rejected: it hides exactly the structure this stack exists to show, and node-count weighting is already flagged as a stand-in (deviation ledger #3). + +**A dedicated sub-call panel instead of in-view nesting.** Rejected: the stack's settled UX is nesting under the parent everywhere; a separate panel would diverge from chat and double the selection plumbing. + +**Defer waterfall lanes until the P-III duration-lane redesign.** Rejected: the sub-lane timing is real today (the pair), and the fraction-of-window rendering is independent of whatever the turn-level lanes become; deferring would strand the stack's timing payoff. + +## Consequences + +The waterfall carries the first REAL wall-time rendering in the client (turn bars remain node-count stand-ins — the contrast is deliberate and labeled by hover titles). Trajectory cell indexes now count sub-calls, so `#N` totals grow on Code Mode turns. Specs pin the interleave order and durations, the running em-dash arm, window fractions (offsets/widths), the running-lane extension, and the rendered lane under the turn row. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md new file mode 100644 index 0000000000..fbfb26c3a6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md @@ -0,0 +1,31 @@ +# Agent Note:trajectory 与 waterfall 视图中的 Code Mode 子调用 + +Status: implemented + +[English](2026-07-26-code-mode-trajectory-waterfall-spans.md) | 中文 + +> 范围:Code Mode UI 堆叠 PR(Pull Request)链的最后一个 PR,涵盖两个非 chat 视图中的子分发渲染。chat 的嵌套归[子调用行 Agent Note](2026-07-26-code-mode-chat-subcall-rows.md)所有;本篇所消费的计时即[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md)的 start/settle 事件对。 + +## 问题 + +trajectory 过去仍把一个 `run_code` 轮次渲染为单个不透明的 Tool 单元格,waterfall 则渲染为一根节点计数条。chat 视图在此前的几个 PR 中已获得嵌套子行,但这两个分析视图(其全部意义恰恰是结构与计时)过去既不显示任何子调用结构,也不显示分发事件对如今已记录的逐子调用墙钟时间。waterfall 的子调用 span 曾被刻意推迟到该事件对存在之后:没有真实计时的 span 就是在撒谎。 + +## 决策 + +**trajectory:`subtool` 单元格穿插在其父 Tool 单元格之后。waterfall:所属轮次行之下、带真实计时的子泳道(sub-lane)。** + +- **trajectory**:布局 fold 接收快照的 `codeDispatches` 索引;凡某个 Tool 单元格的 `callId` 名下存在分发(assistant 块内的调用、孤儿结果与运行中的调用一视同仁),fold 就在该单元格之后按启动顺序为每个子分发穿插一个 `subtool` 单元格,索引在整个穿插序列中保持连续编号。已结算子调用的耗时来自其 start/settle 事件对(`durationSeconds(sub.time, sub.callTime)`);运行中的子调用则显示破折号,与原生的进行中约定完全一致。新增的单元格类型带有 `Sub` 标签(business 色调)与 28px 缩进,嵌套关系一眼可辨。 +- **waterfall**:`deriveSubSpans` 把分发索引折叠成带真实计时的逐轮次泳道:每个父调用的分发窗口为首个 start → 最后一个 settle,每条泳道的偏移/宽度即其在该窗口中的占比,因此并行的子调用(PR3)会肉眼可见地重叠。运行中的泳道以较低的不透明度延伸至窗口末端,耗时为 null。泳道绘制在所属轮次的条形行之下,并缩放进固定的泳道预算。 +- 两个视图都经由标准的快照 hook 读取 `codeDispatches`:没有新的 wire 数据,也没有新的 store;回放的渲染由构造保证与实时完全一致。 + +## 曾考虑的替代方案 + +**把子调用折入轮次 span 的节点计数(给既有的条加权)。** 否决:它隐藏的恰恰是本堆叠 PR 链存在就是为了展示的结构,而且节点计数加权本就已被标记为占位(偏差账本 #3)。 + +**用专用的子调用面板取代视图内嵌套。** 否决:本堆叠 PR 链已敲定的 UX 是处处嵌套在父级之下;独立面板会与 chat 发生偏差,还会让选中接线翻倍。 + +**把 waterfall 泳道推迟到 P-III 的时长泳道重新设计。** 否决:子泳道的计时如今已是真实的(即那对事件),而按窗口占比的渲染与轮次级泳道将来的形态无关;推迟只会让本堆叠 PR 链的计时收益搁浅。 + +## 后果 + +waterfall 承载了 client 中第一处真实的墙钟时间渲染(轮次条仍是节点计数的占位;这一反差是有意为之,并由悬停标题标注)。trajectory 的单元格索引现在会把子调用计入,因此 Code Mode 轮次上的 `#N` 总数会随之增大。spec 锁定穿插顺序与耗时、运行中的破折号分支、窗口占比(偏移/宽度)、运行中泳道的延伸,以及轮次行之下实际渲染出的泳道。 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css index 1120fe2746..c5efc232d1 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css @@ -61,6 +61,17 @@ background: var(--dsw-alias-state-warn-tertiary); } +/* run_code sub-dispatch cells: the business tint plus an indent so the + nesting under the parent Tool cell reads at a glance. */ +.tagSubtool { + color: var(--dsw-alias-state-business-primary); + background: var(--dsw-alias-state-business-tertiary); +} + +.root[data-kind='subtool'] { + padding-left: 28px; +} + .text { flex: 1 1 auto; min-width: 0; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx index de99d027d8..94fc6042a4 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -4,20 +4,23 @@ import type { HTMLAttributes } from 'react' import css from './TrajectoryCell.module.css' -/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */ -export type TrajectoryCellKind = 'user' | 'message' | 'tool' +/** Closed set of trajectory step kinds (call+result fold into Tool; no Think; + * subtool = one run_code sub-dispatch nested under its Tool cell). */ +export type TrajectoryCellKind = 'user' | 'message' | 'tool' | 'subtool' /** Display label per kind (matches the design tags). */ const KIND_LABEL: Record = { user: 'User', message: 'Message', tool: 'Tool', + subtool: 'Sub', } const TAG_CLASS: Record = { user: css.tagUser!, message: css.tagMessage!, tool: css.tagTool!, + subtool: css.tagSubtool!, } export interface TrajectoryCellProps extends HTMLAttributes { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 45277eb628..3d417b085e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -12,9 +12,10 @@ export function TrajectoryView({ useSession }: ConvViewProps) { const nodes = useSession((s) => s.nodes) const partial = useSession((s) => s.partial) const runningCalls = useSession((s) => s.runningCalls) + const codeDispatches = useSession((s) => s.codeDispatches) const turns = useMemo( - () => deriveTrajectoryLayout({ nodes, partial, runningCalls }), - [nodes, partial, runningCalls], + () => deriveTrajectoryLayout({ nodes, partial, runningCalls, codeDispatches }), + [nodes, partial, runningCalls, codeDispatches], ) if (turns.length === 0) { return

暂无轨迹数据

diff --git a/packages/client/ui-trajectory/src/client/WaterfallView.tsx b/packages/client/ui-trajectory/src/client/WaterfallView.tsx index feeb6a7f16..09f26a9425 100644 --- a/packages/client/ui-trajectory/src/client/WaterfallView.tsx +++ b/packages/client/ui-trajectory/src/client/WaterfallView.tsx @@ -1,16 +1,20 @@ -// WaterfallView: P-I placeholder body for the waterfall tab — span stats -// header over node-count bars per turn standing in for duration lanes (no -// timing data yet; deviation ledger #3 defers real rendering to P-III). +// WaterfallView: span stats header over per-turn node-count lanes (P-I +// stand-in for duration lanes; deviation ledger #3). run_code turns +// additionally draw TRUTHFUL sub-call lanes: the dispatch start/settle pair +// carries per-sub-call wall time, so each sub-span's width is its real +// duration against the parent turn's dispatch window. import { useMemo } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { deriveSpans } from './spans.ts' +import { deriveSpans, deriveSubSpans } from './spans.ts' import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx' import css from './views.module.css' /** Bar width scale: px per node, clamped so tiny windows still show a bar. */ const PX_PER_NODE = 14 const MIN_BAR_PX = 8 +/** Sub-span lane width budget (the parent window scales into this). */ +const SUB_LANE_PX = 220 /** Optional density override (test/standalone knob; the register site passes nothing). */ export interface WaterfallExtraProps { @@ -21,27 +25,45 @@ export interface WaterfallExtraProps { export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) { const scale = pxPerNode ?? PX_PER_NODE const nodes = useSession((s) => s.nodes) + const codeDispatches = useSession((s) => s.codeDispatches) const spans = useMemo(() => deriveSpans(nodes), [nodes]) + const subSpans = useMemo(() => deriveSubSpans(nodes, codeDispatches), [nodes, codeDispatches]) if (spans.length === 0) return

暂无瀑布数据

return ( <>
{spans.map((span, i) => ( -
- turn {span.turn} - - {span.calls > 0 && ( +
+
+ turn {span.turn} - )} + {span.calls > 0 && ( + + )} +
+ {(subSpans.get(span.turn) ?? []).map((lane) => ( +
+ {lane.name} + +
+ ))}
))}
diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index e188498554..37c86f6eb4 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -4,6 +4,7 @@ */ import type { AssistantMessageNode, + CodeSubCall, ConversationSnapshot, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -27,6 +28,8 @@ export interface TrajectoryLayoutInput { nodes: ConversationSnapshot['nodes'] partial: ConversationSnapshot['partial'] runningCalls: ConversationSnapshot['runningCalls'] + /** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */ + codeDispatches: ConversationSnapshot['codeDispatches'] } interface UsageLike { @@ -49,7 +52,7 @@ interface LaidCell { * @returns turns ordered by first appearance. */ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { - const { nodes, partial, runningCalls } = input + const { nodes, partial, runningCalls, codeDispatches } = input const resultByCall = indexResults(nodes) const turns = new Map }>() let index = 0 @@ -96,7 +99,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T continue } if (node.kind === 'assistant') { - const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall) + const laidList = withSubCalls(expandAssistant(node, index + 1, prevAbsTime, resultByCall), codeDispatches) for (const laid of laidList) { if (node.step > 0) pushStep(node.turn, node.step, laid) else pushMessage(node.turn, laid) @@ -128,6 +131,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T timeSeconds: durationSeconds(node.time, node.callTime), }, }) + for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) { + pushStep(0, 1, laid) + index = laid.cell.index + } } prevAbsTime = finiteTime(node.time) ?? prevAbsTime } @@ -161,6 +168,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T timeSeconds: null, }, }) + for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) { + pushStep(call.turn, call.step > 0 ? call.step : 1, laid) + index = laid.cell.index + } } // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1. @@ -387,6 +398,53 @@ function collectCallIds( return ids } + + +/** 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 + 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)) { + out.push(sub) + index = sub.cell.index + } + } + return out +} + +/** Sub-dispatch cells for one run_code parent, in start order (running = null duration). */ +function expandSubCalls( + subs: readonly CodeSubCall[] | undefined, + startIndex: number, +): LaidCell[] { + if (subs === undefined || subs.length === 0) return [] + const out: LaidCell[] = [] + let index = startIndex + for (const sub of subs) { + const settled = 'kind' in sub + out.push({ + absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time), + toolName: settled ? sub.call?.name ?? sub.callId : sub.name, + callId: sub.callId, + cell: { + index: ++index, + kind: 'subtool', + text: settled + ? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub)) + : summarizeCall(sub.name, sub.argsRaw), + // PR3's start/settle pair carries per-sub-call wall time; a running + // (unsettled) or pre-pair log entry shows the em dash. + timeSeconds: settled ? durationSeconds(sub.time, sub.callTime) : null, + }, + }) + } + return out +} + function summarizeCall(name: string, argsRaw: string): string { const args = argsRaw.replace(/\s+/g, ' ').trim() if (args === '') return name diff --git a/packages/client/ui-trajectory/src/client/spans.ts b/packages/client/ui-trajectory/src/client/spans.ts index 4957f3762c..d7c86faa9d 100644 --- a/packages/client/ui-trajectory/src/client/spans.ts +++ b/packages/client/ui-trajectory/src/client/spans.ts @@ -5,6 +5,18 @@ */ import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +/** One run_code sub-dispatch lane in the waterfall: real timing off the start/settle pair. */ +export interface SubSpanLane { + callId: string + name: string + /** Wall duration in ms; null while running (start seen, settle not). */ + durationMs: number | null + /** Start offset as a fraction of the parent turn's dispatch window [0, 1). */ + offsetFraction: number + /** Width as a fraction of the window (running lanes extend to the window end). */ + widthFraction: number +} + /** One turn's worth of activity, folded from the snapshot node window. */ export interface TurnSpan { turn: number @@ -69,3 +81,56 @@ export function deriveSpanStats(spans: readonly TurnSpan[]): SpanStats { function hasTurn(node: ConversationNode): node is ConversationNode & { turn: number } { return node.kind === 'assistant' || node.kind === 'steering' } + +/** + * Fold the dispatch index into per-turn sub-span lanes with REAL timing: each + * lane's offset/width scale against its parent turn's dispatch window (first + * start → last settle). Running (unsettled) lanes extend to the window end + * with a null duration. + * @param nodes - snapshot nodes (locates each parent run_code call's turn). + * @param codeDispatches - the snapshot's dispatch index. + * @returns lanes keyed by turn, in start order. + */ +export function deriveSubSpans( + nodes: ConversationSnapshot['nodes'], + codeDispatches: ConversationSnapshot['codeDispatches'], +): ReadonlyMap { + const out = new Map() + if (codeDispatches.size === 0) return out + const turnByCall = new Map() + let currentTurn = 0 + for (const node of nodes) { + if (node.kind === 'assistant' || node.kind === 'steering') currentTurn = node.turn + if (node.kind === 'tool-result') turnByCall.set(node.callId, currentTurn) + } + for (const [parent, subs] of codeDispatches) { + if (subs.length === 0) continue + const turn = turnByCall.get(parent) ?? currentTurn + const starts: number[] = [] + const ends: number[] = [] + for (const sub of subs) { + const settled = 'kind' in sub + const start = settled ? sub.callTime ?? sub.time : sub.time + starts.push(start) + ends.push(settled ? sub.time : start) + } + const windowStart = Math.min(...starts) + const windowEnd = Math.max(...ends, windowStart + 1) + const windowSpan = windowEnd - windowStart + const lanes: SubSpanLane[] = subs.map((sub, i) => { + const settled = 'kind' in sub + const start = starts[i] ?? windowStart + const end = settled ? sub.time : windowEnd + return { + callId: sub.callId, + name: settled ? sub.call?.name ?? sub.callId : sub.name, + durationMs: settled ? Math.max(0, sub.time - start) : null, + offsetFraction: (start - windowStart) / windowSpan, + widthFraction: Math.max((end - start) / windowSpan, 0.02), + } + }) + const existing = out.get(turn) ?? [] + out.set(turn, [...existing, ...lanes]) + } + return out +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index d3089b3568..920478b1f0 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -45,3 +45,32 @@ color: var(--dsw-alias-label-caption); font: var(--dsw-font-xs-13); } + +/* run_code sub-span lanes: one row per sub-dispatch under its turn row, + offset/width scaled to the dispatch window (real wall time). A running + lane pulses via reduced opacity until its settle arrives. */ +.subRow { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; +} + +.subTag { + flex: none; + width: 88px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} + +.barSub { + height: 8px; + background: var(--dsw-alias-state-business-primary); +} + +.barSub[data-running] { + opacity: 0.45; +} diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index 9773f6fe57..b74782a4c4 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -70,7 +70,7 @@ describe('deriveTrajectoryLayout', () => { content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null, }, ] as unknown as ConversationSnapshot['nodes'] - const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), 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)) @@ -86,6 +86,7 @@ describe('deriveTrajectoryLayout', () => { it('adds runningCalls not already present and leaves their time blank', () => { const turns = deriveTrajectoryLayout({ + codeDispatches: new Map(), nodes: [] as unknown as ConversationSnapshot['nodes'], partial: null, runningCalls: [{ @@ -111,7 +112,7 @@ describe('deriveTrajectoryLayout', () => { usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 }, }, ] as unknown as ConversationSnapshot['nodes'] - const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), 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() @@ -137,7 +138,7 @@ describe('deriveTrajectoryLayout', () => { content: [], isError: false, callView: null, resultView: null, }, ] as unknown as ConversationSnapshot['nodes'] - const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') }) @@ -154,7 +155,7 @@ describe('deriveTrajectoryLayout', () => { blocks: [{ kind: 'text', text: 'ok2' }], }, ] as unknown as ConversationSnapshot['nodes'] - const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), 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']) @@ -168,7 +169,7 @@ describe('deriveTrajectoryLayout', () => { usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 }, }, ] as unknown as ConversationSnapshot['nodes'] - const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), 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, @@ -196,7 +197,7 @@ describe('deriveTrajectoryLayout', () => { blocks: [{ kind: 'text', text: 'done' }], }, ] as unknown as ConversationSnapshot['nodes'] - const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups .flatMap((g) => g.cells) .find((c) => c.kind === 'message' && c.text === 'done') @@ -204,3 +205,51 @@ describe('deriveTrajectoryLayout', () => { expect(message?.timeSeconds).toBe(1) }) }) + +describe('run_code sub-dispatch cells', () => { + const runCodeNodes = [ + { + kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1, + blocks: [ + { kind: 'tool-call', callId: 'p1', name: 'run_code', argsRaw: '{"code":"…","description":"批量读取"}' }, + ], + }, + { + 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, + }, + ] as unknown as ConversationSnapshot['nodes'] + + const settledSub = (n: number, name: string, start: number, end: number) => ({ + kind: 'tool-result' as const, seq: 100 + n, time: end, + callId: `p1:code:${n}`, + call: { name, argsRaw: '{"x":1}' }, callTime: start, + content: [{ type: 'text' as const, text: 'ok' }], isError: false, callView: null, resultView: null, + }) + + it('nests settled sub-cells after their parent Tool cell with real durations', () => { + const codeDispatches = new Map([['p1', [ + 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 cells = turns[0]!.groups.flatMap((g) => g.cells) + expect(cells.map((c) => c.kind)).toEqual(['tool', 'subtool', 'subtool']) + // Sequential indexes across the interleave; durations from the pair times. + expect(cells.map((c) => c.index)).toEqual([1, 2, 3]) + expect(cells[1]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 }) + expect(cells[2]).toMatchObject({ timeSeconds: 0.5 }) + }) + + 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, + } + const codeDispatches = new Map([['p1', [running]]]) as unknown as ConversationSnapshot['codeDispatches'] + const turns = deriveTrajectoryLayout({ codeDispatches, nodes: runCodeNodes, 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 }) + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index c1e6331ef6..8559991889 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -21,7 +21,7 @@ import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversa import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' -import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts' +import { deriveSpans, deriveSpanStats, deriveSubSpans } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts' import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx' import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx' import { WaterfallView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/WaterfallView.tsx' @@ -54,7 +54,7 @@ const NODES = [ function fakeSession(nodes: ConversationSnapshot['nodes']) { const store = createSnapshotStore({ - nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], + nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(), }) return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } } @@ -117,7 +117,7 @@ function tabsOf(slots: SlotsService): ViewTab[] { function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { const sessionSnapshot = createSnapshotStore({ running: false, removed: false, promptError: null, nodes, - partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], + partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches: new Map(), }) const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession const chat = createChatStore().create() @@ -260,3 +260,78 @@ describe('node half', () => { expect(nodeApply()).toBeUndefined() }) }) + +describe('deriveSubSpans (waterfall lanes)', () => { + const dispatchNodes = [ + { kind: 'assistant', seq: 2, time: 6_000, turn: 3, step: 1, blocks: [] }, + { + kind: 'tool-result', seq: 3, time: 9_000, callId: 'p1', + call: { name: 'run_code', argsRaw: '{}' }, callTime: 6_100, + content: [], isError: false, callView: null, resultView: null, + }, + ] as unknown as ConversationSnapshot['nodes'] + + it('scales settled lanes into the dispatch window with real durations', () => { + const codeDispatches = new Map([['p1', [ + { + kind: 'tool-result', seq: 101, time: 7_000, callId: 'p1:code:1', + call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200, + content: [], isError: false, callView: null, resultView: null, + }, + { + kind: 'tool-result', seq: 102, time: 8_200, callId: 'p1:code:2', + call: { name: 'read', argsRaw: '{}' }, callTime: 7_000, + content: [], isError: false, callView: null, resultView: null, + }, + ]]]) as unknown as ConversationSnapshot['codeDispatches'] + const lanes = deriveSubSpans(dispatchNodes, codeDispatches) + const turn3 = lanes.get(3) + expect(turn3).toHaveLength(2) + // Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0. + expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, offsetFraction: 0 }) + expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4) + expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 }) + expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4) + }) + + it('a running lane extends to the window end with a null duration', () => { + const codeDispatches = new Map([['p1', [ + { + kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1', + call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200, + content: [], isError: false, callView: null, resultView: null, + }, + { callId: 'p1:code:2', name: 'grep', argsRaw: '{}', turn: 0, step: 0, time: 7_000, callView: null }, + ]]]) as unknown as ConversationSnapshot['codeDispatches'] + const lanes = deriveSubSpans(dispatchNodes, codeDispatches) + const running = lanes.get(3)?.find((lane) => lane.name === 'grep') + expect(running).toMatchObject({ durationMs: null }) + // Extends from its start to the window end. + expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1) + }) + + it('waterfall renders sub-span lanes under the owning turn row', () => { + const codeDispatches = new Map([['p1', [ + { + kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1', + call: { name: 'bash', argsRaw: '{}' }, callTime: 6_200, + content: [], isError: false, callView: null, resultView: null, + }, + ]]]) as unknown as ConversationSnapshot['codeDispatches'] + const store = createSnapshotStore({ + nodes: dispatchNodes, partial: null, + runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches, + }) + const props = { + sessionId: SID, + useSession: bindSnapshotSelector(store) as unknown as UseSession, + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + } as unknown as ConvViewProps + const view = render(createElement(WaterfallView as FC, props)) + const lane = view.container.querySelector('[data-subspan]') + expect(lane).not.toBeNull() + expect(lane!.textContent).toContain('bash') + expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull() + }) +}) From 9d5b54529db95e71cd91724a77c45f5ad3ff1fdb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:04:07 +0800 Subject: [PATCH 2/3] test(snapshots): refresh cordis-inspect-jsdoc against the stack's registry JSDoc The scenario pins the registry's own API JSDoc, which grew the shapeDispatchLog/CodeDispatchLog contracts on this stack. --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 487f0517b1..efb527afae 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch\n * and return the content the bridge should log on `tool/code-dispatch`.\n * Contained: a throwing listener falls back to the unshaped content — log\n * shaping must never fail the dispatch or lose the settle event.\n * @param dispatch - the sub-dispatch identity and its default logged content.\n * @returns the (possibly reshaped) content for the durable event.\n */\n async shapeDispatchLog(dispatch: CodeDispatchLog): Promise\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n followup(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n queue(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: SendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: InjectOptions): AgentMessageId;\n send(input: ResolvedAgentInput): AgentMessageId;\n cancel(cause?: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export type AgentMessageId = Branded<'AgentMessageId'>;\n export interface AgentOptions {\n provider?: string;\n model?: string;\n }\n export type AgentStatus = 'idle' | 'running' | 'disposed';\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface CodeDispatchLog {\n readonly exec: ToolExecution;\n readonly agent?: Agent;\n readonly subCallId: CallId;\n readonly name: string;\n readonly isError: boolean;\n readonly content: ContentBlock[];\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n messagePrefix?: Message[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface HookContext {\n content: ContentBlock[];\n source: MessageSource;\n placement?: 'separate' | 'prompt-prefix';\n meta?: JsonValue;\n }\n export interface InjectOptions {\n source?: MessageSource;\n meta?: JsonValue;\n }\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n role: 'system' | 'user' | 'assistant';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n }\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n }\n export interface PromptMessageData {\n content: ContentBlock[];\n source: MessageSource;\n envelope?: PromptMessageEnvelope;\n meta?: JsonValue;\n }\n export interface PromptMessageEnvelope {\n displayContent: ContentBlock[];\n prefixContexts: PromptPrefixContext[];\n }\n export interface PromptPrefixContext {\n source: MessageSource;\n meta?: JsonValue;\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ResolvedAgentInput = {\n content: ContentBlock[];\n source: MessageSource;\n meta: JsonValue | undefined;\n } & ({\n target: 'next-turn';\n wakeup: boolean;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: true;\n contexts: HookContext[];\n } | {\n target: 'next-step';\n wakeup: false;\n contexts: [\n ];\n });\n export type ScopeKey = object;\n export interface SendOptions {\n source?: MessageSource;\n contexts?: HookContext[];\n meta?: JsonValue;\n }\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': PromptMessageData;\n 'prompt/blocked': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n content: ContentBlock[];\n provenance: AssistantProvenance;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': PromptMessageData & {\n turn: number;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: HookContext[];\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: HookContext): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n rejected: {\n kind: 'rejected';\n reason: string;\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From 835156b0384a1f1dc6a740bdb320d08614cfde17 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:20:59 +0800 Subject: [PATCH 3/3] fix(ui-trajectory): timing provenance on sub-span lanes; assembled snapshot for both views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responding to ds-review-bot on #664: - SubSpanLane gains a 'timing' discriminant (measured | running | unknown). A settle-only replay entry (callTime null, start outside the window) was previously indistinguishable from a measured 0 ms span; it now renders hollow with a 'duration unknown' hover title, and durationMs stays null for anything unmeasured. Pairs with the client-runtime fix that stopped fabricating callTime = settle time (826c3696a on the live-parallel PR). - The built-client Code Mode fixture snapshot now switches to the Trajectory and Waterfall tabs and pins the assembled rendering: three Sub cells with real +0.8s durations and three measured lanes with their hover titles — product-visible coverage through the real bundle graph, not just package-level jsdom fixtures. Agent Note (both languages) updated for the timing contract; pairing re-recorded. --- ...-mode-trajectory-waterfall-spans.i18n.yaml | 4 +- ...26-code-mode-trajectory-waterfall-spans.md | 4 +- ...code-mode-trajectory-waterfall-spans.zh.md | 4 +- apps/web/tests/code-mode-fixture.snapshot.ts | 64 ++++++++++++++++++- .../src/client/WaterfallView.tsx | 7 +- .../client/ui-trajectory/src/client/spans.ts | 15 ++++- .../ui-trajectory/src/client/views.module.css | 8 ++- .../client/ui-trajectory/tests/views.spec.tsx | 41 +++++++++++- 8 files changed, 133 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml index ac38a7465f..233e1ce72a 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml @@ -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 -2026-07-26-code-mode-trajectory-waterfall-spans.md: 54449bcf8612a39461a769173d7f60c742f67ad8 -2026-07-26-code-mode-trajectory-waterfall-spans.zh.md: fbfb26c3a62554d60a6cb561ead78e10cd4115cd +2026-07-26-code-mode-trajectory-waterfall-spans.md: fe4dcc25dbf211cf69e0d33937cf87a7482852e2 +2026-07-26-code-mode-trajectory-waterfall-spans.zh.md: aaae06b1fca1b5587d06aa7704adec421d2b2c27 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md index 54449bcf86..fe4dcc25db 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md @@ -15,7 +15,7 @@ Trajectory and waterfall still rendered a `run_code` turn as one opaque Tool cel **Trajectory: `subtool` cells interleaved after their parent Tool cell. Waterfall: real-time sub-lanes under the owning turn row.** - **Trajectory**: the layout fold takes the snapshot's `codeDispatches` index; after each Tool cell whose `callId` has dispatches (assistant-block calls, orphan results, and running calls alike), it interleaves one `subtool` cell per sub-dispatch in start order — indexes stay sequential across the interleave. A settled sub-call's duration is its start/settle pair (`durationSeconds(sub.time, sub.callTime)`); a running one shows the em dash, exactly the native in-flight convention. The new cell kind wears a `Sub` tag (business tint) and a 28px indent so nesting reads at a glance. -- **Waterfall**: `deriveSubSpans` folds the dispatch index into per-turn lanes with REAL timing — each parent's dispatch window is first start → last settle, and every lane's offset/width is its fraction of that window, so parallel sub-calls (PR3) visibly overlap. Running lanes extend to the window end at reduced opacity with a null duration. Lanes draw under the owning turn's bar row, scaled into a fixed lane budget. +- **Waterfall**: `deriveSubSpans` folds the dispatch index into per-turn lanes with REAL timing — each parent's dispatch window is first start → last settle, and every lane's offset/width is its fraction of that window, so parallel sub-calls (PR3) visibly overlap. Each lane carries a `timing` provenance tag: `measured` (pair observed), `running` (settle pending — extends to the window end at reduced opacity), or `unknown` (settle-only replay window, `callTime: null` — drawn hollow and titled "duration unknown", never a fabricated 0 ms). Lanes draw under the owning turn's bar row, scaled into a fixed lane budget. - Both views read `codeDispatches` through the standard snapshot hook — no new wire data, no new stores; replay renders identically to live by construction. ## Alternatives considered @@ -28,4 +28,4 @@ Trajectory and waterfall still rendered a `run_code` turn as one opaque Tool cel ## Consequences -The waterfall carries the first REAL wall-time rendering in the client (turn bars remain node-count stand-ins — the contrast is deliberate and labeled by hover titles). Trajectory cell indexes now count sub-calls, so `#N` totals grow on Code Mode turns. Specs pin the interleave order and durations, the running em-dash arm, window fractions (offsets/widths), the running-lane extension, and the rendered lane under the turn row. +The waterfall carries the first REAL wall-time rendering in the client (turn bars remain node-count stand-ins — the contrast is deliberate and labeled by hover titles). Trajectory cell indexes now count sub-calls, so `#N` totals grow on Code Mode turns. Specs pin the interleave order and durations, the running em-dash arm, window fractions (offsets/widths), the running-lane extension, the unknown-timing (settle-only) lane, and the rendered lane under the turn row; the built-client Code Mode fixture snapshot additionally pins both tabs' assembled rendering (sub-cells with real +0.8s durations, measured lanes). diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md index fbfb26c3a6..aaae06b1fc 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md @@ -15,7 +15,7 @@ trajectory 过去仍把一个 `run_code` 轮次渲染为单个不透明的 Tool **trajectory:`subtool` 单元格穿插在其父 Tool 单元格之后。waterfall:所属轮次行之下、带真实计时的子泳道(sub-lane)。** - **trajectory**:布局 fold 接收快照的 `codeDispatches` 索引;凡某个 Tool 单元格的 `callId` 名下存在分发(assistant 块内的调用、孤儿结果与运行中的调用一视同仁),fold 就在该单元格之后按启动顺序为每个子分发穿插一个 `subtool` 单元格,索引在整个穿插序列中保持连续编号。已结算子调用的耗时来自其 start/settle 事件对(`durationSeconds(sub.time, sub.callTime)`);运行中的子调用则显示破折号,与原生的进行中约定完全一致。新增的单元格类型带有 `Sub` 标签(business 色调)与 28px 缩进,嵌套关系一眼可辨。 -- **waterfall**:`deriveSubSpans` 把分发索引折叠成带真实计时的逐轮次泳道:每个父调用的分发窗口为首个 start → 最后一个 settle,每条泳道的偏移/宽度即其在该窗口中的占比,因此并行的子调用(PR3)会肉眼可见地重叠。运行中的泳道以较低的不透明度延伸至窗口末端,耗时为 null。泳道绘制在所属轮次的条形行之下,并缩放进固定的泳道预算。 +- **waterfall**:`deriveSubSpans` 把分发索引折叠成带真实计时的逐轮次泳道:每个父调用的分发窗口为首个 start → 最后一个 settle,每条泳道的偏移/宽度即其在该窗口中的占比,因此并行的子调用(PR3)会肉眼可见地重叠。每条泳道带有 `timing` 来源标记:`measured`(观察到了成对事件)、`running`(settle 未到 — 以较低不透明度延伸至窗口末端)或 `unknown`(回放窗口只含 settle、`callTime: null` — 画成空心并以「duration unknown」为悬停标题,绝不伪造 0 ms)。泳道绘制在所属轮次的条形行之下,并缩放进固定的泳道预算。 - 两个视图都经由标准的快照 hook 读取 `codeDispatches`:没有新的 wire 数据,也没有新的 store;回放的渲染由构造保证与实时完全一致。 ## 曾考虑的替代方案 @@ -28,4 +28,4 @@ trajectory 过去仍把一个 `run_code` 轮次渲染为单个不透明的 Tool ## 后果 -waterfall 承载了 client 中第一处真实的墙钟时间渲染(轮次条仍是节点计数的占位;这一反差是有意为之,并由悬停标题标注)。trajectory 的单元格索引现在会把子调用计入,因此 Code Mode 轮次上的 `#N` 总数会随之增大。spec 锁定穿插顺序与耗时、运行中的破折号分支、窗口占比(偏移/宽度)、运行中泳道的延伸,以及轮次行之下实际渲染出的泳道。 +waterfall 承载了 client 中第一处真实的墙钟时间渲染(轮次条仍是节点计数的占位;这一反差是有意为之,并由悬停标题标注)。trajectory 的单元格索引现在会把子调用计入,因此 Code Mode 轮次上的 `#N` 总数会随之增大。spec 锁定穿插顺序与耗时、运行中的破折号分支、窗口占比(偏移/宽度)、运行中泳道的延伸、unknown 计时(仅 settle)泳道,以及轮次行之下实际渲染出的泳道;构建产物级的 Code Mode fixture 快照另行锁定两个标签页的组装后渲染(带真实 +0.8s 耗时的子单元格、measured 泳道)。 diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index 5abf8bc6c0..e042e857c0 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -5,7 +5,8 @@ // the code-variant parent row titled by the model-authored description, its // three always-visible nested sub-rows (bash through the sample registration, // read through GenericToolCard, the failing read wearing the error state), -// the expanded program body, and details-panel resolution of a sub-callId. +// the expanded program body, details-panel resolution of a sub-callId, and +// the trajectory/waterfall tabs' sub-call cells and timing lanes. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -177,3 +178,64 @@ it('expands the code row into the program body and resolves a sub-row through th } `) }) + +it('trajectory and waterfall surface the run_code sub-calls with real timing', async () => { + boot() + await openFixtureSession() + + // Switch to the trajectory tab (same slot ring the chat view registers in). + fireEvent.click(await screen.findByRole('tab', { name: 'Trajectory' })) + await waitFor(() => { + expect(document.querySelector('[data-kind="subtool"]')).not.toBeNull() + }, { timeout: 10_000 }) + const subCells = [...document.querySelectorAll('[data-kind="subtool"]')] + expect({ + // Three Sub cells nested under the run_code Tool cell, in dispatch order, + // each with a real +N.Ns own-duration off the start/settle pair (the + // fixture spaces every event 800ms apart — never the em dash). + subCells: subCells.map(cell => visibleText(cell)), + }).toMatchInlineSnapshot(` + { + "subCells": [ + "#53Subbash · {"command":"ls notes","description":"List notes"}+0.8s", + "#54Subread · {"path":"notes/demo.txt"}+0.8s", + "#55Subread · {"path":"notes/missing.txt"}+0.8s", + ], + } + `) + + // Waterfall: each sub-call draws a measured lane scaled into the parent + // turn's dispatch window. + fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' })) + await waitFor(() => { + expect(document.querySelector('[data-subspan]')).not.toBeNull() + }, { timeout: 10_000 }) + const lanes = [...document.querySelectorAll('[data-subspan]')] + expect({ + lanes: lanes.map(lane => ({ + label: visibleText(lane.querySelector('[class*="subTag"]') ?? lane), + title: lane.querySelector('[data-timing]')?.getAttribute('title'), + timing: lane.querySelector('[data-timing]')?.getAttribute('data-timing'), + })), + }).toMatchInlineSnapshot(` + { + "lanes": [ + { + "label": "bash", + "timing": "measured", + "title": "bash · 0.80s", + }, + { + "label": "read", + "timing": "measured", + "title": "read · 0.80s", + }, + { + "label": "read", + "timing": "measured", + "title": "read · 0.80s", + }, + ], + } + `) +}) diff --git a/packages/client/ui-trajectory/src/client/WaterfallView.tsx b/packages/client/ui-trajectory/src/client/WaterfallView.tsx index 09f26a9425..ad81845fb9 100644 --- a/packages/client/ui-trajectory/src/client/WaterfallView.tsx +++ b/packages/client/ui-trajectory/src/client/WaterfallView.tsx @@ -55,12 +55,15 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa {lane.name}
))} diff --git a/packages/client/ui-trajectory/src/client/spans.ts b/packages/client/ui-trajectory/src/client/spans.ts index d7c86faa9d..585a336333 100644 --- a/packages/client/ui-trajectory/src/client/spans.ts +++ b/packages/client/ui-trajectory/src/client/spans.ts @@ -9,8 +9,14 @@ import type { ConversationNode, ConversationSnapshot } from '@deepseek-ai/dsh-cl export interface SubSpanLane { callId: string name: string - /** Wall duration in ms; null while running (start seen, settle not). */ + /** Wall duration in ms; null unless both endpoints were observed (`timing: 'measured'`). */ durationMs: number | null + /** + * Timing provenance: `measured` = start/settle pair observed; `running` = + * start seen, settle pending; `unknown` = settle-only replay window (the + * start fell outside), so no duration claim is possible. + */ + timing: 'measured' | 'running' | 'unknown' /** Start offset as a fraction of the parent turn's dispatch window [0, 1). */ offsetFraction: number /** Width as a fraction of the window (running lanes extend to the window end). */ @@ -106,6 +112,9 @@ export function deriveSubSpans( for (const [parent, subs] of codeDispatches) { if (subs.length === 0) continue const turn = turnByCall.get(parent) ?? currentTurn + // A settle-only entry (callTime null: its start fell outside the replay + // window) anchors the window by its settle time — a real observation — + // but must never masquerade as a measured zero-duration span. const starts: number[] = [] const ends: number[] = [] for (const sub of subs) { @@ -119,12 +128,14 @@ export function deriveSubSpans( const windowSpan = windowEnd - windowStart const lanes: SubSpanLane[] = subs.map((sub, i) => { const settled = 'kind' in sub + const timing = settled ? (sub.callTime === null ? 'unknown' as const : 'measured' as const) : 'running' as const const start = starts[i] ?? windowStart const end = settled ? sub.time : windowEnd return { callId: sub.callId, name: settled ? sub.call?.name ?? sub.callId : sub.name, - durationMs: settled ? Math.max(0, sub.time - start) : null, + durationMs: timing === 'measured' ? Math.max(0, end - start) : null, + timing, offsetFraction: (start - windowStart) / windowSpan, widthFraction: Math.max((end - start) / windowSpan, 0.02), } diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 920478b1f0..16a853c441 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -71,6 +71,12 @@ background: var(--dsw-alias-state-business-primary); } -.barSub[data-running] { +.barSub[data-timing='running'] { opacity: 0.45; } + +/* Settle-only replay entries: no measured span — hollow, not a solid bar. */ +.barSub[data-timing='unknown'] { + background: transparent; + border: 1px dashed var(--dsw-alias-state-business-primary); +} diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 8559991889..485db395eb 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -288,7 +288,7 @@ describe('deriveSubSpans (waterfall lanes)', () => { const turn3 = lanes.get(3) expect(turn3).toHaveLength(2) // Window = 6200..8200 (2000ms). bash: 0..0.4; read: 0.4..1.0. - expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, offsetFraction: 0 }) + expect(turn3?.[0]).toMatchObject({ name: 'bash', durationMs: 800, timing: 'measured', offsetFraction: 0 }) expect(turn3?.[0]?.widthFraction).toBeCloseTo(0.4) expect(turn3?.[1]).toMatchObject({ name: 'read', durationMs: 1200 }) expect(turn3?.[1]?.offsetFraction).toBeCloseTo(0.4) @@ -305,11 +305,23 @@ describe('deriveSubSpans (waterfall lanes)', () => { ]]]) as unknown as ConversationSnapshot['codeDispatches'] const lanes = deriveSubSpans(dispatchNodes, codeDispatches) const running = lanes.get(3)?.find((lane) => lane.name === 'grep') - expect(running).toMatchObject({ durationMs: null }) + expect(running).toMatchObject({ durationMs: null, timing: 'running' }) // Extends from its start to the window end. expect(running!.offsetFraction + running!.widthFraction).toBeCloseTo(1) }) + it('a settle-only entry (null callTime) is unknown timing, never a measured 0 ms', () => { + const codeDispatches = new Map([['p1', [ + { + kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1', + call: { name: 'bash', argsRaw: '{}' }, callTime: null, + content: [], isError: false, callView: null, resultView: null, + }, + ]]]) as unknown as ConversationSnapshot['codeDispatches'] + const lane = deriveSubSpans(dispatchNodes, codeDispatches).get(3)?.[0] + expect(lane).toMatchObject({ durationMs: null, timing: 'unknown' }) + }) + it('waterfall renders sub-span lanes under the owning turn row', () => { const codeDispatches = new Map([['p1', [ { @@ -333,5 +345,30 @@ describe('deriveSubSpans (waterfall lanes)', () => { expect(lane).not.toBeNull() expect(lane!.textContent).toContain('bash') expect(lane!.querySelector('[title*="1.80s"]')).not.toBeNull() + expect(lane!.querySelector('[data-timing="measured"]')).not.toBeNull() + }) + + it('waterfall labels a settle-only lane as duration unknown', () => { + const codeDispatches = new Map([['p1', [ + { + kind: 'tool-result', seq: 101, time: 8_000, callId: 'p1:code:1', + call: { name: 'read', argsRaw: '{}' }, callTime: null, + content: [], isError: false, callView: null, resultView: null, + }, + ]]]) as unknown as ConversationSnapshot['codeDispatches'] + const store = createSnapshotStore({ + nodes: dispatchNodes, partial: null, + runningCalls: [] as ConversationSnapshot['runningCalls'], codeDispatches, + }) + const props = { + sessionId: SID, + useSession: bindSnapshotSelector(store) as unknown as UseSession, + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + } as unknown as ConvViewProps + const view = render(createElement(WaterfallView as FC, props)) + const bar = view.container.querySelector('[data-timing="unknown"]') + expect(bar).not.toBeNull() + expect(bar!.getAttribute('title')).toContain('duration unknown') }) })