From 4987261d554161b47e82f7e6809d45899eff9509 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:01:03 +0800 Subject: [PATCH 01/23] feat(spill): bound the durable copy of Code Mode sub-dispatch results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New tools/code-dispatch-log waterfall (run via registry.shapeDispatchLog, contained — a throwing listener falls back to the unshaped content) lets listeners reshape the tool/code-dispatch event's content before the bridge appends it. dsh-spill-policy registers a second arm sharing the model-facing arm's exact replacement pipeline (same maxInlineBytes cap, preview + locator, within-cap invariant, best-effort fallbacks), with artifacts labeled dispatch under the sub-call id. The program's value is untouched; read sub-calls ARE bounded (a log copy is not model context, and read produces the biggest logs). Resolves the tools README's uncapped-dispatch-log Known Limitation. --- ...26-07-26-code-dispatch-log-spill.i18n.yaml | 6 + .../2026-07-26-code-dispatch-log-spill.md | 31 ++++ .../2026-07-26-code-dispatch-log-spill.zh.md | 31 ++++ docs/config-catalog.md | 12 +- docs/core-data-structures/tools.i18n.yaml | 4 +- docs/core-data-structures/tools.md | 26 +++ docs/core-data-structures/tools.zh.md | 26 +++ docs/event-producer-consumer.md | 5 +- .../core/scope/src/scoped-events.generated.ts | 1 + packages/core/tools/README.md | 2 +- packages/core/tools/src/code-mode.ts | 36 ++-- packages/core/tools/src/index.ts | 55 ++++++ packages/spill/spill-policy/README.md | 4 +- packages/spill/spill-policy/src/index.ts | 159 ++++++++++++------ .../spill-policy/tests/spill-policy.spec.ts | 91 ++++++++++ scripts/gen-cordis-catalog.ts | 1 + 16 files changed, 415 insertions(+), 75 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md create mode 100644 .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml new file mode 100644 index 0000000000..f00ecd5d2a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.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-dispatch-log-spill.md: 2668c195a43ae1f6011c09413338a23caf75401e +2026-07-26-code-dispatch-log-spill.zh.md: e084ae80d7fed864c7f296b1fd6db713acf7a2b0 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md new file mode 100644 index 0000000000..2668c195a4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md @@ -0,0 +1,31 @@ +# Agent Note: Spilling the durable copy of Code Mode sub-dispatch results + +Status: implemented + +English | [中文](2026-07-26-code-dispatch-log-spill.zh.md) + +> Scope: the fourth PR of the Code Mode UI stack — bounding the `tool/code-dispatch` event's content with the existing spill machinery. The [host foundation note](2026-07-26-code-dispatch-ui-foundation.md) accepted the unbounded log deliberately and named this PR as the payoff point; the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) settled the event pair this shaping hooks into. + +## Problem + +Since the full-content dispatch logging landed, a `run_code` program that reads a large file wrote the complete rendered text into the session log — uncapped and outside spill policy, while native results were bounded to `maxInlineBytes` before logging. The asymmetry was backwards: sub-calls (built for bulk data work) were precisely the calls most likely to carry huge results, and the JSONL grew by megabytes per such turn. + +## Decision + +**A log-shaping waterfall on the registry, and the spill policy as its first listener.** + +- **Seam**: `tools/code-dispatch-log` — a scope-filtered waterfall the bridge runs (via `registry.shapeDispatchLog`, contained: a throwing listener falls back to the unshaped content) over each settled sub-dispatch before appending `tool/code-dispatch`. The payload (`CodeDispatchLog`) carries the outer execution, the hoisted `agent` routing key, the sub-call identity, and the default content. Only the durable copy is shapeable — the program already received the complete value across the worker boundary, and the model sees neither. +- **Policy**: `dsh-spill-policy` registers a second arm on the new seam sharing the exact replacement pipeline of its model-facing arm (same `maxInlineBytes` cap, same preview + locator + within-cap invariant, same best-effort fallbacks), with the artifact labeled `dispatch` under the sub-call id. UIs and replay read the full text through the spill artifact exactly as they do for spilled native results, so the native-parity rendering story survives bounding. +- **One deliberate asymmetry**: the model-facing arm skips `read` (the `read → spill → read again` loop); the dispatch-log arm bounds `read` sub-calls too — a log copy is not model context, so the loop cannot happen, and `read` is precisely the tool that produces huge logs. + +## Alternatives considered + +**Bound inside the bridge with a plain cap (no spill).** Rejected: truncation without a locator loses data replay/UIs may need, and re-introduces the "truncated summary" degraded render path the stack removed. + +**Spill inside the bridge directly (call `ctx.spillStore` from code-mode.ts).** Rejected: the registry would grow a hard dependency on the spill capability; the waterfall keeps the policy where every other spill decision lives, composable and disable-able (omitted `maxInlineBytes` still means a true no-op). + +**Reuse `tools/post-execute` for nested calls instead of a new event.** Rejected: post-execute shapes the PROGRAM-facing result (nested calls deliberately skip it so programs get complete data); the durable copy needs its own decision point after the program has its value. + +## Consequences + +The session log is bounded again for Code Mode turns — the README's Known Limitations entry about uncapped dispatch logging is resolved and now points here. Old logs with oversized dispatch content still replay (the event shape is unchanged; only future appends shrink). The web UI renders spilled sub-call output as the preview + locator text through the identical native path, no special casing. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md new file mode 100644 index 0000000000..e084ae80d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md @@ -0,0 +1,31 @@ +# Agent Note:将 Code Mode 子分发结果的持久副本纳入 spill 机制 + +Status: implemented + +[English](2026-07-26-code-dispatch-log-spill.md) | 中文 + +> 范围:Code Mode UI 堆叠 PR(Pull Request)链的第四个 PR,即用既有的 spill 机制为 `tool/code-dispatch` 事件的内容施加边界。[宿主侧基础 Agent Note](2026-07-26-code-dispatch-ui-foundation.md)当初有意接受了不设上限的日志,并指明本 PR 就是兑现点;[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md)敲定了本次整形所挂接的事件对。 + +## 问题 + +自携带完整内容的分发日志落地以来,读取大文件的 `run_code` 程序过去会把完整的渲染文本写进会话日志,不设上限、位于 spill 策略之外;而原生结果在记录之前就已被限制在 `maxInlineBytes` 以内。这种不对称的方向完全反了:子调用(本就为批量数据工作而设计)恰恰是最可能携带巨大结果的调用,而每个这样的轮次都会让 JSONL 增长数 MB。 + +## 决策 + +**在注册表上增设一个日志整形 waterfall(瀑布式事件),spill 策略作为其第一个监听器。** + +- **Seam**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由 `registry.shapeDispatchLog`,且故障被兜住:监听器抛出异常时回退到未整形的内容)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容。可整形的只有持久副本:程序已经跨 worker 边界收到了完整的值,而模型两者都看不到。 +- **策略**:`dsh-spill-policy` 在新 seam 上注册第二个分支,与其面向模型的分支共用一模一样的替换流水线(同样的 `maxInlineBytes` 上限、同样的预览 + 定位符 + 不超上限不变式、同样的尽力而为回退),产物以 `dispatch` 为标签,记在子调用 id 名下。UI 与回放通过 spill 产物读取全文,方式与读取被 spill 的原生结果完全相同,因此与原生同等保真的渲染在施加边界之后依然成立。 +- **一处有意的不对称**:面向模型的分支跳过 `read`(避免 `read → spill → read again` 循环);分发日志分支则连 `read` 子调用也施加边界:日志副本不是模型上下文,该循环因此不可能发生,而 `read` 恰恰是会产生巨大日志的那个工具。 + +## 曾考虑的替代方案 + +**在桥接层内部用普通上限施加边界(不做 spill)。** 否决:没有定位符的截断会丢失回放与 UI 可能需要的数据,还会重新引入本堆叠 PR 链已经移除的「截断摘要」降级渲染路径。 + +**直接在桥接层内做 spill(从 code-mode.ts 调用 `ctx.spillStore`)。** 否决:注册表会因此对 spill 能力产生硬依赖;waterfall 则把策略留在所有其他 spill 决策所在的地方,既可组合也可禁用(省略 `maxInlineBytes` 依然意味着真正的 no-op)。 + +**让嵌套调用复用 `tools/post-execute`,而不是新增一个事件。** 否决:post-execute 整形的是面向程序的那份结果(嵌套调用有意跳过它,好让程序拿到完整数据);持久副本需要一个属于自己的决策点,位于程序取得其值之后。 + +## 后果 + +对 Code Mode 轮次而言,会话日志重新有了边界:README 中关于分发日志不设上限的 Known Limitations 条目已经解决,现在指向本篇。携带超大分发内容的旧日志仍可回放(事件形状未变;只有今后的追加才会变小)。web UI 经由与原生完全相同的路径,把被 spill 的子调用输出渲染为预览 + 定位符文本,没有任何特殊处理。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51f57f7bde..eeaed0302a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1202,7 +1202,7 @@ export interface Config { } ``` -Source: [`packages/spill/spill-policy/src/index.ts:51`](../packages/spill/spill-policy/src/index.ts) +Source: [`packages/spill/spill-policy/src/index.ts:60`](../packages/spill/spill-policy/src/index.ts) ## `@deepseek-ai/dsh-storage-domain` @@ -1704,13 +1704,21 @@ export interface Config { * absent or mismatched. Under `code`, native names in `toolOrder` are invalid. */ mode?: ToolPresentationMode + /** + * Concurrency cap for a `run_code` program's overlapping sub-calls + * (default 10, the loop scheduler's own default). Sub-calls follow the + * native scheduling contract — only calls whose tools classify + * concurrency-safe overlap; exclusive calls form barriers — so `1` + * restores strictly serial dispatch. Must be a positive integer. + */ + maxParallelSubCalls?: number } /** How the registry presents its tools to the model (see {@link Config.mode}). */ export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:529`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:562`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index c96584f032..19c6cb4612 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.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 -tools.md: 875bea18ff0c34ca97f9c144f4320d3b3a6aaa4a -tools.zh.md: 11f0b8d4a0f29304e6fdbde7c81be981bd940a2d +tools.md: 389c54bf625f762257a4830ed915d526230090ab +tools.zh.md: fba3453fa91be2544eb3ab94ca67aaf0452958b2 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 875bea18ff..389c54bf62 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -231,6 +231,32 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` +Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/code-dispatch-log` waterfall, which may reshape the durable event's copy of the content (the program's value and the model contract are untouched): + +```ts type-equiv +/** + * One settled `run_code` sub-dispatch about to be logged, as seen by the + * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * outer call identity), the sub-call identity, and the outcome whose durable + * copy a listener may reshape. The complete `content` is what the program + * already received; only the `tool/code-dispatch` event's copy changes. + */ +interface CodeDispatchLog { + /** The outer `run_code` execution. */ + readonly exec: ToolExecution + /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ + readonly agent?: Agent + /** Deterministic sub-call id (`:code:`). */ + readonly subCallId: CallId + /** The dispatched sub-tool name. */ + readonly name: string + /** Whether the sub-call settled as an error. */ + readonly isError: boolean + /** The sub-call's complete model-facing content (the settle event's default payload). */ + readonly content: ContentBlock[] +} +``` + ```ts type-equiv /** * One pending tool call inside the registry pipeline. Parsed arguments cross diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 11f0b8d4a0..fba3453fa9 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -231,6 +231,32 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` +Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code-dispatch-log` waterfall,该 waterfall 可以改写持久事件所存的内容副本(程序取得的值与模型契约均不受影响): + +```ts type-equiv +/** + * One settled `run_code` sub-dispatch about to be logged, as seen by the + * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * outer call identity), the sub-call identity, and the outcome whose durable + * copy a listener may reshape. The complete `content` is what the program + * already received; only the `tool/code-dispatch` event's copy changes. + */ +interface CodeDispatchLog { + /** The outer `run_code` execution. */ + readonly exec: ToolExecution + /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ + readonly agent?: Agent + /** Deterministic sub-call id (`:code:`). */ + readonly subCallId: CallId + /** The dispatched sub-tool name. */ + readonly name: string + /** Whether the sub-call settled as an error. */ + readonly isError: boolean + /** The sub-call's complete model-facing content (the settle event's default payload). */ + readonly content: ContentBlock[] +} +``` + ```ts type-equiv /** * One pending tool call inside the registry pipeline. Parsed arguments cross diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d9521f7d67..880cde9fd8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -44,11 +44,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:156`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | +| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | -| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:133`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index a12b0a513e..728ee2a8e8 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -35,6 +35,7 @@ const scopedSubjectResolvers: Readonly (args[1] as Record)['scope'], + 'tools/code-dispatch-log': args => (args[0] as Record)['agent'], 'tools/execute': args => (args[0] as Record)['agent'], 'tools/post-execute': args => (args[0] as Record)['agent'], 'tools/pre-execute': args => (args[0] as Record)['agent'], diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 9c7e7a7857..5aaea1d296 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -189,5 +189,5 @@ Append-only; newly visible content follows the reusable request prefix and does - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. - **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. -- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The rendered `content` of every sub-call IS logged verbatim on `tool/code-dispatch`, uncapped and outside spill policy, so programs that read huge files grow the session log by the same bytes (spill integration for the logged copy is deferred work). +- **Code Mode intermediate values are execution-local and unbounded by bytes** — the canonical typed values cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. The durable log copy of each sub-call IS bounded: the `tools/code-dispatch-log` waterfall lets the spill policy replace an oversized `tool/code-dispatch` content with a preview + locator ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)). - **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP (cross-call state would be invisible to the log); see [the Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md). diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 20f0aa47d1..a01ab0f0eb 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -331,19 +331,29 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => for (const context of result.additionalContexts ?? []) { exec.deferContext(context) } - exec.agent?.session.append('tool/code-dispatch', { - parentCallId: exec.callId, - subCallId, - name, - // The SIBLING parse of the dispatched value: byte-identical JSON, - // but a separate object — a tool mutating its args cannot desync - // this record from what it actually received. - arguments: normalized.logged, - isError: result.isError, - // The registry deep-froze this projection at result finalization; - // append snapshots it again, so the log copy stays detached. - content: result.content, - }) + if (exec.agent !== undefined) { + // The durable copy may be reshaped (e.g. spilled to a preview + + // locator) by the log-shaping waterfall; the program's value and + // the model contract are untouched. + const logged = await registry.shapeDispatchLog({ + exec, agent: exec.agent, subCallId, name, isError: result.isError, + // The registry deep-froze this projection at result + // finalization; append snapshots the final copy again, so the + // log stays detached. + content: result.content, + }) + exec.agent.session.append('tool/code-dispatch', { + parentCallId: exec.callId, + subCallId, + name, + // The SIBLING parse of the dispatched value: byte-identical JSON, + // but a separate object — a tool mutating its args cannot desync + // this record from what it actually received. + arguments: normalized.logged, + isError: result.isError, + content: logged, + }) + } resolve(result.isError ? { isError: true, message: result.error.message } : { isError: false, value: result.value }) diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 68a7cefcd4..0593e6ec51 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -123,6 +123,19 @@ declare module 'cordis' { * @mode waterfall */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise + /** + * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before + * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the unshaped content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ + 'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise /** * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. @@ -272,6 +285,28 @@ export type ToolExecutionMode = | { kind: 'parallel' } | { kind: 'exclusive' } +/** + * One settled `run_code` sub-dispatch about to be logged, as seen by the + * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * outer call identity), the sub-call identity, and the outcome whose durable + * copy a listener may reshape. The complete `content` is what the program + * already received; only the `tool/code-dispatch` event's copy changes. + */ +export interface CodeDispatchLog { + /** The outer `run_code` execution. */ + readonly exec: ToolExecution + /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ + readonly agent?: Agent + /** Deterministic sub-call id (`:code:`). */ + readonly subCallId: CallId + /** The dispatched sub-tool name. */ + readonly name: string + /** Whether the sub-call settled as an error. */ + readonly isError: boolean + /** The sub-call's complete model-facing content (the settle event's default payload). */ + readonly content: ContentBlock[] +} + /** * One pending tool call inside the registry pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; @@ -932,6 +967,26 @@ export class ToolRegistry extends Service { } } + /** + * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch + * and return the content the bridge should log on `tool/code-dispatch`. + * Contained: a throwing listener falls back to the unshaped content — log + * shaping must never fail the dispatch or lose the settle event. + * @param dispatch - the sub-dispatch identity and its default logged content. + * @returns the (possibly reshaped) content for the durable event. + */ + async shapeDispatchLog(dispatch: CodeDispatchLog): Promise { + try { + return await this.ctx.waterfall( + scopeTarget(this, dispatch.agent), 'tools/code-dispatch-log', dispatch, + () => Promise.resolve(dispatch.content), + ) + } catch (error: unknown) { + this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${String(error)}; logging the unshaped content`) + return dispatch.content + } + } + /** * Execute through pre-policy, guards, around-dispatch, post-policy, * definition-owned content finalization, and final notification. Tool and diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md index cf46ccafd6..3e89e22f9c 100644 --- a/packages/spill/spill-policy/README.md +++ b/packages/spill/spill-policy/README.md @@ -13,7 +13,7 @@ This plugin registers **no service** and owns no storage or preview mechanics: p ## Behavior 1. Let the tool run (delegates via `next()`, so it bounds whatever a downstream hook accepted). -2. Skip nested executions (`exec.parent` is present), accepted value replacements (the registry must revalidate and rerender them), `read` (avoids a `read → spill → read again` loop), and any non-`accept` decision (a `block`'s corrective feedback passes through). +2. Skip nested executions (`exec.parent` is present — their DURABLE copy is bounded by the dispatch-log arm below), accepted value replacements (the registry must revalidate and rerender them), `read` (avoids a `read → spill → read again` loop), and any non-`accept` decision (a `block`'s corrective feedback passes through). 3. Flatten the accepted content only when it is **plain text** (all `text` blocks); a result with any non-text block is left untouched. 4. If its UTF-8 size is `≤ maxInlineBytes`, leave it unchanged. 5. Otherwise save the full text and replace the result with a preview + this notice, sized so the whole replacement (preview + blank line + notice) stays within `maxInlineBytes` — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap: @@ -28,6 +28,8 @@ This plugin registers **no service** and owns no storage or preview mechanics: p **Best-effort:** no session owner, no `ctx.spillStore` backend, or a `saveText` rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an `isError` or hides the inline result. A successful replacement changes only `content`; the canonical programmatic value is preserved. +**The dispatch-log arm:** a second listener on `tools/code-dispatch-log` applies the same cap, replacement pipeline, and best-effort fallbacks to the DURABLE copy of each `run_code` sub-call result (artifact label `dispatch`, keyed by the sub-call id). The program's value is untouched — it already crossed the worker boundary whole — and `read` sub-calls are bounded too: a log copy is not model context, so the read-again loop cannot occur, and `read` is precisely the tool that produces huge logs ([rationale](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md)). + ## Scope The policy sees only the FINAL formatted surface result—not a tool's internal resource or canonical value. If a provider already truncated (e.g. `web-fetch-local.maxBodyChars`), the spill artifact holds the full formatted result the tool returned, not the full original source. Provider/resource caps stay mandatory and separate. `glob`/`grep` own item-level surface spill because their complete acquired values still exist before rendering; bash streams own acquisition-time spill. The generic policy prepends its waterfall listener, then delegates, so ordinary tool-owned asynchronous projections complete before generic byte bounding regardless of plugin load order. See the [tool output spill Agent Note](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md). diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts index 26c501257c..470fd1cacd 100644 --- a/packages/spill/spill-policy/src/index.ts +++ b/packages/spill/spill-policy/src/index.ts @@ -10,18 +10,26 @@ * `@deepseek-ai/dsh-retention` (`TextRetainer`), storage is `ctx.spillStore`. * The policy only decides WHEN to spill and composes the notice. * + * A second arm applies the SAME cap to the durable log: the + * `tools/code-dispatch-log` waterfall bounds the `tool/code-dispatch` event's + * copy of an oversized `run_code` sub-call result (the program's value is + * untouched; UIs and replay read the full text through the spill artifact). + * * ## Deliberately narrow * * - Omitted `maxInlineBytes` ⇒ the plugin registers nothing (a true no-op). * - Plain-text results only: a result carrying any non-text block is left * untouched (the policy knows only the final formatted text, not tool * internals). - * - Nested composite calls are skipped; only their outer surface result may - * become model-facing and spillable. + * - Nested composite calls skip the MODEL-facing arm; their durable log copy + * is bounded by the dispatch-log arm instead. * - Accepted value replacements pass through for registry revalidation and * rendering; this presentation policy cannot also replace content in the * same mutually exclusive decision. - * - `read` is skipped to avoid a `read → spill → read again` loop. + * - `read` is skipped by the model-facing arm to avoid a + * `read → spill → read again` loop; the dispatch-log arm bounds `read` + * sub-calls too (a log copy is not model context, and `read` is precisely + * the tool that produces huge logs). * - Best-effort: no session owner, no `ctx.spillStore` backend, or a save * failure ⇒ log and return the original result. A spill failure must NEVER * turn a successful tool call into an `isError` or hide the inline result. @@ -42,6 +50,7 @@ import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-retention' import type { Omitted } from '@deepseek-ai/dsh-retention' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import type { SessionId } from '@deepseek-ai/dsh-session' +import type { CallId } from '@deepseek-ai/dsh-llm' import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' import type { SpillPolicyExec } from './types.ts' @@ -108,6 +117,75 @@ export function apply(ctx: Context, config: Config): void { if (!Number.isInteger(maxInlineBytes) || maxInlineBytes < 0) { throw new Error(`spill-policy: maxInlineBytes must be a non-negative integer (got ${maxInlineBytes})`) } + // Narrowed once for the nested arms (closure narrowing does not survive awaits). + const cap: number = maxInlineBytes + + /** + * Spill `text` and build the bounded replacement (preview + notice), or + * return `undefined` when the policy must keep the original (no session + * owner, no backend, storage failure, or no within-cap replacement). + * Shared verbatim by the model-facing post-execute arm and the durable + * dispatch-log arm so both produce byte-identical projections. + */ + async function spillReplacement( + text: string, + totalBytes: number, + sessionId: SessionId | undefined, + toolName: string, + callId: CallId, + label: 'result' | 'dispatch', + ): Promise { + if (sessionId === undefined) { + ctx.logger.warn(`spill-policy: no session owner for ${toolName} ${label}; keeping the inline content`) + return undefined + } + const spillStore = ctx.get('spillStore') + if (!spillStore) { + ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline content') + return undefined + } + const save: SaveTextSpill = { + owner: { sessionId }, + source: { toolName, callId, label }, + suggestedName: `${toolName}.txt`, + content: text, + } + let ref: SpillRef + try { + ref = await spillStore.saveText(save) + } catch (error: unknown) { + // Best-effort: a storage failure (permissions, ENOSPC, backend down) must + // never fail the call or hide the content — keep the original inline. + ctx.logger.warn(`spill-policy: saveText failed for ${toolName}: ${String(error)}; keeping the inline content`) + return undefined + } + + // Reserve the notice's byte cost INSIDE maxInlineBytes so the replacement + // (preview + blank line + notice) never exceeds the documented cap — a naive + // preview that spent the whole budget then appended the notice could be + // larger than the cap, and for a marginally-over result even larger than the + // original. The reservation uses a notice priced at the worst-case omission + // count (the full byte total): its digit count bounds the real count's, so + // the reserved size is a safe upper bound and the final notice is never + // longer than what we reserved. `\n\n` is the 2-byte join. + const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2 + const previewBudget = Math.max(0, cap - reserve) + const { text: previewText, omitted } = preview(text, previewBudget) + const notice = spillNotice(omitted, ref) + const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice + // Invariant: the policy NEVER emits a replacement larger than the cap. When + // the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root), + // there is no within-cap replacement, so keep the inline content — spilling + // would break the advertised cap. (A within-cap replacement is always + // smaller than the original, which is > cap by the entry condition, so this + // one check subsumes "not smaller than the original" too. The spill file + // already written is a harmless orphan; cleanup is deferred.) + if (Buffer.byteLength(replacedText, 'utf8') > cap) { + ctx.logger.warn(`spill-policy: spill notice for ${toolName} exceeds maxInlineBytes; keeping the inline content`) + return undefined + } + return replacedText + } ctx.on('tools/post-execute', async (exec, result, next): Promise => { // Delegate first so a downstream listener (e.g. a hook) settles the result; @@ -124,58 +202,31 @@ export function apply(ctx: Context, config: Config): void { const totalBytes = Buffer.byteLength(text, 'utf8') if (totalBytes <= maxInlineBytes) return decision - const sessionId = ownerSessionId(exec) - if (sessionId === undefined) { - ctx.logger.warn(`spill-policy: no session owner for ${exec.name} result; keeping the inline result`) - return decision - } - const spillStore = ctx.get('spillStore') - if (!spillStore) { - ctx.logger.warn('spill-policy: no ctx.spillStore backend loaded; keeping the inline result') - return decision - } - - const save: SaveTextSpill = { - owner: { sessionId }, - source: { toolName: exec.name, callId: exec.callId, label: 'result' }, - suggestedName: `${exec.name}.txt`, - content: text, - } - let ref: SpillRef - try { - ref = await spillStore.saveText(save) - } catch (error: unknown) { - // Best-effort: a storage failure (permissions, ENOSPC, backend down) must - // never fail the call or hide the result — keep the original inline. - ctx.logger.warn(`spill-policy: saveText failed for ${exec.name}: ${String(error)}; keeping the inline result`) - return decision - } - - // Reserve the notice's byte cost INSIDE maxInlineBytes so the replacement - // (preview + blank line + notice) never exceeds the documented cap — a naive - // preview that spent the whole budget then appended the notice could be - // larger than the cap, and for a marginally-over result even larger than the - // original. The reservation uses a notice priced at the worst-case omission - // count (the full byte total): its digit count bounds the real count's, so - // the reserved size is a safe upper bound and the final notice is never - // longer than what we reserved. `\n\n` is the 2-byte join. - const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2 - const previewBudget = Math.max(0, maxInlineBytes - reserve) - const { text: previewText, omitted } = preview(text, previewBudget) - const notice = spillNotice(omitted, ref) - const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice - // Invariant: the policy NEVER emits a replacement larger than the cap. When - // the notice alone exceeds maxInlineBytes (a tiny cap or a long spill root), - // there is no within-cap replacement, so keep the inline result — spilling - // would break the advertised context cap. (A within-cap replacement is - // always smaller than the original, which is > cap by the entry condition, - // so this one check subsumes "not smaller than the original" too. The spill - // file already written is a harmless orphan; cleanup is deferred.) - if (Buffer.byteLength(replacedText, 'utf8') > maxInlineBytes) { - ctx.logger.warn(`spill-policy: spill notice for ${exec.name} exceeds maxInlineBytes; keeping the inline result`) - return decision - } + const replacedText = await spillReplacement(text, totalBytes, ownerSessionId(exec), exec.name, exec.callId, 'result') + if (replacedText === undefined) return decision const replaced: ContentBlock[] = [{ type: 'text', text: replacedText }] return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} } }, { prepend: true }) + + // The durable-log arm: bound the `tool/code-dispatch` event's copy of an + // oversized sub-call result the same way the model-facing arm bounds an + // outer result. The program's returned value is untouched (it already + // crossed the worker boundary whole); only the session log's copy shrinks + // to preview + locator, so replay and UIs read the full text through the + // spill artifact exactly as they do for spilled native results. + ctx.on('tools/code-dispatch-log', async (dispatch, next): Promise => { + const content = await next() + // `read` sub-calls spill too: the log copy is not model context, so the + // read → spill → read-again loop the post-execute arm avoids cannot + // happen here, and read is precisely the tool that produces huge logs. + const text = flattenPlainText(content) + if (text === undefined) return content + const totalBytes = Buffer.byteLength(text, 'utf8') + if (totalBytes <= maxInlineBytes) return content + + const replacedText = await spillReplacement( + text, totalBytes, ownerSessionId(dispatch.exec), dispatch.name, dispatch.subCallId, 'dispatch') + if (replacedText === undefined) return content + return [{ type: 'text', text: replacedText }] + }, { prepend: true }) } diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 120baf197e..33a9aa7cee 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -230,6 +230,97 @@ describe('read skip', () => { }) }) +describe('the durable dispatch-log arm', () => { + /** Boot code mode + the policy + the worker runtime; run one program via the real bridge. */ + async function runCodeWith(program: string, maxInlineBytes: number) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: 'code' }) + await ctx.plugin(StubStore) + await ctx.plugin(SpillPolicy, { maxInlineBytes }) + await ctx.plugin(WorkerCodeRuntime, {}) + const events: { type: string; data: unknown }[] = [] + const agent = { + session: { + header: { id: SessionId('dispatch-spill'), cwd: '/workspace' }, + append: (type: string, data: unknown) => { events.push({ type, data }) }, + }, + } + ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000))) + ctx.tools.register(textTool('small_read', 'tiny')) + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('parent-1'), + name: 'run_code', + arguments: { code: program, description: 'Drive dispatch-log spilling' }, + agent: agent as never, + }) + return { ctx, result, events, spill: ctx.spillStore as StubStore } + } + + it('bounds the tool/code-dispatch copy of an oversized sub-result while the program value stays whole', async () => { + const { result, events, spill } = await runCodeWith( + 'const blocks = await tools.huge_read({});\nreturn blocks[0].text.length', 200) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected success') + // The program received the COMPLETE text (length 2000), untouched by spill. + expect(result.value).toMatchObject({ result: 2_000 }) + // The durable settle event carries the bounded projection + locator. + const settle = events.find(event => event.type === 'tool/code-dispatch') + expect(settle).toBeDefined() + const logged = (settle!.data as { content: { type: string; text: string }[] }).content + expect(logged).toHaveLength(1) + const loggedText = logged[0]!.text + expect(Buffer.byteLength(loggedText, 'utf8')).toBeLessThanOrEqual(200) + expect(loggedText).toContain('Full formatted result stored at: /spill/huge_read.txt') + // The artifact holds the full text under the dispatch label and sub-call id. + const save = spill.saves.find(entry => entry.source.label === 'dispatch') + expect(save).toMatchObject({ + source: { toolName: 'huge_read', callId: 'parent-1:code:1', label: 'dispatch' }, + }) + expect(save?.content).toBe('H'.repeat(2_000)) + }) + + it('leaves a within-cap sub-result log untouched and saves nothing for it', async () => { + const { events, spill } = await runCodeWith( + 'return await tools.small_read({})', 200) + const settle = events.find(event => event.type === 'tool/code-dispatch') + expect((settle!.data as { content: { type: string; text: string }[] }).content) + .toEqual([{ type: 'text', text: 'tiny' }]) + expect(spill.saves.filter(entry => entry.source.label === 'dispatch')).toHaveLength(0) + }) + + it('a saveText failure keeps the complete content in the durable log (best-effort)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: 'code' }) + await ctx.plugin(StubStore) + await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 }) + await ctx.plugin(WorkerCodeRuntime, {}) + ;(ctx.spillStore as StubStore).fail = true + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const events: { type: string; data: unknown }[] = [] + const agent = { + session: { + header: { id: SessionId('dispatch-spill-fail'), cwd: '/workspace' }, + append: (type: string, data: unknown) => { events.push({ type, data }) }, + }, + } + ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000))) + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('parent-2'), + name: 'run_code', + arguments: { code: 'return (await tools.huge_read({}))[0].text.length', description: 'Fail the spill backend' }, + agent: agent as never, + }) + expect(result.isError).toBe(false) + const settle = events.find(event => event.type === 'tool/code-dispatch') + expect((settle!.data as { content: { text: string }[] }).content[0]!.text).toBe('H'.repeat(2_000)) + expect(warn).toHaveBeenCalled() + }) +}) + describe('nested-call skip', () => { it('leaves nested composite results complete and spillable only through their outer call', async () => { const { ctx, spill } = await setup({ maxInlineBytes: 10 }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b53d1eaa12..ca62d42ffb 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -165,6 +165,7 @@ export const LINK_MAP: Record = { TaskSnapshot: 'tasks.md', TaskStart: 'tasks.md', TokenMeasurement: 'token-meter.md', + CodeDispatchLog: 'tools.md', PostToolDecision: 'tools.md', PreToolDecision: 'tools.md', ToolDefinition: 'tools.md', From bb3dc50a4bc073d4887b5c96f907ac242dfc05fa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:52:37 +0800 Subject: [PATCH 02/23] feat(web): shiki syntax highlighting for code surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One highlighter for the client: a synchronous fine-grained shiki core (JS regex engine, no WASM) in ui-primitives with an explicit grammar allowlist (typescript, shellscript, json — aliases resolve, unknown languages take a geometry-identical plain arm). The shared CodeBlock component owns both arms; markdown fences, the run_code expanded program body (typescript), and the details panel Input (json) all route through it. Token colors live in a new ui-theme shiki.css sheet as --shiki-* custom properties (light/dark blocks), wired through the shell's base.css chain — tokens-only styling holds; shiki's generated span tree is the sanctioned innerHTML path (static output, no user HTML). jsdom specs pin token spans, aliases, both fallbacks, and the fence route; the built-bundle snapshot asserts the highlighted program under the code row. --- ...026-07-26-web-syntax-highlighting-shiki.md | 32 ++++++ apps/web/tests/code-mode-fixture.snapshot.ts | 11 +- .../src/client/chat/ToolRow.module.css | 15 +-- .../src/client/chat/ToolRow.tsx | 6 +- .../src/client/skeleton/DetailsPanel.tsx | 3 +- .../tests/chat-code-subcalls.spec.tsx | 9 +- packages/client/ui-primitives/package.json | 4 +- packages/client/ui-primitives/src/index.ts | 1 + .../src/markdown/CodeBlock.module.css | 27 +++++ .../ui-primitives/src/markdown/CodeBlock.tsx | 37 +++++++ .../src/markdown/MarkdownText.tsx | 15 +++ .../ui-primitives/src/markdown/highlight.ts | 68 ++++++++++++ .../ui-primitives/tests/code-block.spec.tsx | 53 +++++++++ .../ui-primitives/tests/markdown.spec.tsx | 2 + packages/client/ui-theme/src/styles/shiki.css | 31 ++++++ packages/client/web/src/base.css | 3 +- pnpm-lock.yaml | 101 ++++++++++++++++++ 17 files changed, 399 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md create mode 100644 packages/client/ui-primitives/src/markdown/CodeBlock.module.css create mode 100644 packages/client/ui-primitives/src/markdown/CodeBlock.tsx create mode 100644 packages/client/ui-primitives/src/markdown/highlight.ts create mode 100644 packages/client/ui-primitives/tests/code-block.spec.tsx create mode 100644 packages/client/ui-theme/src/styles/shiki.css diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md new file mode 100644 index 0000000000..79ad2153b8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md @@ -0,0 +1,32 @@ +# Agent Note: Web client syntax highlighting — synchronous fine-grained shiki + +Status: implemented + +English | [中文](2026-07-26-web-syntax-highlighting-shiki.zh.md) + +> Scope: the web client's one syntax-highlighting system — the dependency ruling, the singleton shape, the token-sheet contract, and the consuming surfaces. Fifth PR of the Code Mode UI stack; the [chat sub-call rows note](../feature/2026-07-26-code-mode-chat-subcall-rows.md) shipped the `run_code` program body this exists to make readable. Styling ground rules are owned by [the web styling ruling](2026-07-19-web-styling-system.md). + +## Problem + +The client rendered every code surface — markdown fences in assistant prose, the `run_code` program body, the details panel's args — as flat monospace text. The stack's primary payload is model-written TypeScript; unhighlighted programs are measurably harder to scan, and the repo already ships shiki-highlighted code on its VitePress site, so the web app was the one code-rendering surface without it. + +## Decision + +**Shiki in its synchronous fine-grained form, as one `ui-primitives` singleton, themed exclusively through CSS custom properties.** + +- **Dependency**: `shiki/core` + `@shikijs/langs`, composed via `createHighlighterCoreSync` with `createJavaScriptRegexEngine({ forgiving: true })` — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: `typescript` (embeds JS), `shellscript`, `json` — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here. +- **Singleton**: `ui-primitives/src/markdown/highlight.ts` lazily creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path. +- **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in a new `ui-theme/styles/shiki.css` token sheet (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by the shell's `base.css` chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree. +- **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps. + +## Alternatives considered + +**`rehype-highlight`/lowlight.** Runner-up: naturally sync and ~⅓ the bundle, but regex-grammar fidelity on TypeScript is visibly worse, and the repo would then run two highlighter systems (site: shiki, app: highlight.js) with two theming vocabularies. + +**Full `shiki` bundle or the oniguruma WASM engine.** Rejected: the full bundle ships every grammar/theme; WASM needs async loading the sync client boot deliberately avoids. The fine-grained core with three grammars keeps the cost proportional to actual use. + +**Highlight in a worker / async.** Rejected: the payloads are small (programs, fences, args); the synchronous JS engine tokenizes them in microseconds, and async introduces a flash-of-unhighlighted-code plus render-machinery churn for no measured need. + +## Consequences + +One code surface for every consumer — a future surface imports `CodeBlock` and inherits highlighting, theming, and the plain fallback. The bundle grows by the shiki core + three grammars (paid once in `ui-primitives`). Token colors are the first `--shiki-*` sheet; a theme package registering alias overrides extends them like any other token. jsdom specs pin the token-span structure, alias resolution, both fallback arms, and the fence route; the existing built-bundle snapshot and browser e2e cover the assembled path. diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index e862474348..5abf8bc6c0 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -139,13 +139,20 @@ it('expands the code row into the program body and resolves a sub-row through th boot() await openFixtureSession() - // Expand: the leading control reveals the program verbatim. + // Expand: the leading control reveals the program (shiki-tokenized: the + // text splits into styled spans inside one
 tree).
   const codeRoot = document.querySelector('[data-variant="code"]')
   if (codeRoot === null) throw new Error('code-variant row missing')
   const toggle = codeRoot.querySelector('button[aria-expanded]')
   if (toggle === null) throw new Error('code row expand control missing')
   fireEvent.click(toggle)
-  await screen.findByText(/const listing = await tools\.bash/)
+  await waitFor(() => {
+    // Scope to THIS row: the markdown fixture turn also renders shiki pres.
+    const pre = codeRoot.querySelector('pre.shiki')
+    if (pre === null || !(pre.textContent ?? '').includes('const listing = await tools.bash')) {
+      throw new Error('highlighted program body missing under the code row')
+    }
+  })
 
   // Sub-row click → details panel resolves the sub-callId with FULL output.
   const nest = document.querySelector('[data-subcalls]')
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
index 204af4573d..16878ae91e 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -87,14 +87,9 @@ button.leading {
   color: var(--dsw-alias-label-tertiary);
 }
 
-/* The code variant's expanded body is the run_code program: monospace on the
-   markdown code-block fill so the program reads as code, not prose. */
-.root[data-variant='code'] .body {
-  font-family: var(--ds-font-family-code);
-  font-size: 13px;
-  line-height: 20px;
-  padding: 6px 8px;
-  margin-left: 22px;
-  border-radius: 6px;
-  background: var(--dsw-alias-markdown-code-block);
+/* The code variant's expanded body is the run_code program, rendered through
+   the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
+   this row's concern. */
+.codeBody {
+  margin: 4px 0 4px 22px;
 }
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index f1a5ce7440..113241eb5d 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -6,7 +6,7 @@
 
 import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
 import clsx from 'clsx'
-import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
 import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
 import css from './ToolRow.module.css'
@@ -96,7 +96,9 @@ export function ToolRow({
           
         )}
       
-      {open && 
{body}
} + {open && (variant === 'code' + ? + :
{body}
)} ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 6d998aeff9..3d3c84a646 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -5,6 +5,7 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. +import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' @@ -89,7 +90,7 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane {material.argsRaw !== null && (
Input
-
{pretty(material.argsRaw)}
+
)}
diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 4751ea815e..2d61edae1c 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -152,7 +152,7 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(view.getByText('Tool call')).toBeTruthy() }) - it('expanding the code row reveals the program body verbatim', async () => { + 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 view = mountApp(b.slots) @@ -160,7 +160,12 @@ describe('run_code sub-calls through the real chat machinery', () => { const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]') expect(toggle).not.toBeNull() fireEvent.click(toggle!) - expect(view.getByText(/const listing = await tools\.bash/)).toBeTruthy() + // Shiki splits the program into token spans inside one
:
+    // assert the whole text and the highlighted tree rather than one node.
+    const pre = view.container.querySelector('pre.shiki')
+    expect(pre).not.toBeNull()
+    expect(pre!.textContent).toContain('const listing = await tools.bash')
+    expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(3)
   })
 
   it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json
index 7f5c3555bd..9ce2bc8676 100644
--- a/packages/client/ui-primitives/package.json
+++ b/packages/client/ui-primitives/package.json
@@ -20,11 +20,13 @@
   },
   "license": "BSD-3-Clause",
   "dependencies": {
+    "@shikijs/langs": "^4.3.1",
     "clsx": "^2.0.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-markdown": "^10.1.0",
-    "remark-gfm": "^4.0.1"
+    "remark-gfm": "^4.0.1",
+    "shiki": "^4.3.1"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-invariants": "workspace:^",
diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts
index 9fd3d149fc..11460779a5 100644
--- a/packages/client/ui-primitives/src/index.ts
+++ b/packages/client/ui-primitives/src/index.ts
@@ -16,6 +16,7 @@ export { FishLogo } from './FishLogo.tsx'
 export { BrandWordmark } from './BrandWordmark.tsx'
 export { Tooltip } from './Tooltip.tsx'
 export type { TooltipSide } from './Tooltip.tsx'
+export { CodeBlock } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
 export { MessageText } from './markdown/MessageText.tsx'
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css
new file mode 100644
index 0000000000..f9b5f67136
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css
@@ -0,0 +1,27 @@
+/* One code-block geometry for highlighted and plain arms: the shiki 
+   and the fallback 
 draw identically except for token colors. */
+
+.block :where(pre) {
+  margin: 0;
+  padding: 8px 10px;
+  border-radius: 8px;
+  overflow-x: auto;
+  background: var(--dsw-alias-markdown-code-block);
+  font: var(--dsw-font-markdown-code-block);
+}
+
+/* Shiki inlines its theme background var; route it to the repo token. */
+.block :where(pre.shiki) {
+  background: var(--dsw-alias-markdown-code-block) !important;
+}
+
+.block :where(pre) code {
+  font: inherit;
+  background: none;
+  padding: 0;
+}
+
+.plain {
+  color: var(--dsw-alias-label-primary);
+  white-space: pre;
+}
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
new file mode 100644
index 0000000000..1a6349f1e8
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -0,0 +1,37 @@
+// CodeBlock: one code surface for every consumer — markdown fences, the
+// run_code program body, and the details panel's raw args/output — with
+// shiki highlighting for the registered grammars and an identical-geometry
+// plain fallback for everything else. Shiki emits a single 
+// tree of nested spans whose colors are --shiki-* custom properties
+// (token sheets own the values); it produces no scripts or event handlers,
+// so injecting its output is safe by construction.
+
+import { useMemo } from 'react'
+import clsx from 'clsx'
+import { highlightToHtml } from './highlight.ts'
+import css from './CodeBlock.module.css'
+
+export interface CodeBlockProps {
+  /** The source text, rendered verbatim (trailing newline trimmed for display). */
+  code: string
+  /** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
+  lang?: string | undefined
+  /** Extra class merged onto the wrapper (callers position; this component draws). */
+  className?: string | undefined
+}
+
+export function CodeBlock({ code, lang, className }: CodeBlockProps) {
+  const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
+  const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
+  if (html === undefined) {
+    return (
+      
+
{trimmed}
+
+ ) + } + // eslint-disable-next-line react/no-danger -- shiki's output is a static + // span tree it generated from `code` (no user HTML passes through), the + // sanctioned innerHTML consumption path per shiki's own docs. + return
+} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 425e3969ab..f74e939246 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -1,6 +1,8 @@ +import { isValidElement } from 'react' import ReactMarkdown from 'react-markdown' import type { Components, UrlTransform } from 'react-markdown' import remarkGfm from 'remark-gfm' +import { CodeBlock } from './CodeBlock.tsx' import css from './MarkdownText.module.css' const remarkPlugins = [remarkGfm] @@ -42,6 +44,19 @@ const components: Components = { {children}
), + // Fenced blocks route through the shared CodeBlock (shiki for registered + // grammars, identical-geometry plain fallback for unknown/absent languages); + // inline code keeps the default path (the :not(pre) rule styles it). + pre: ({ children }) => { + const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined + const raw = child?.props.children + const text = typeof raw === 'string' ? raw : Array.isArray(raw) && typeof raw[0] === 'string' ? raw[0] : undefined + // A fence whose content isn't one plain string (never produced by the + // markdown pipeline) keeps the stock
 rather than guessing.
+    if (text === undefined) return 
{children}
+ const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1] + return + }, } /** diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts new file mode 100644 index 0000000000..34e0359f60 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -0,0 +1,68 @@ +/** + * The client's ONE syntax highlighter: a synchronous fine-grained shiki core + * (JavaScript regex engine — no oniguruma WASM, bundle-friendly) with an + * explicit grammar allowlist and a CSS-variables theme. Colors live in the + * theme package's token sheets as `--shiki-*` custom properties (light and + * dark blocks), never here — the repo's tokens-only styling rule. + * + * Grammars are the set the harness actually renders: TypeScript programs + * (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands, + * and JSON payloads. An unknown or absent language falls back to plain text + * (no highlighting, still monospace) — never an error. + */ + +import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core' +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' +import langTs from '@shikijs/langs/typescript' +import langBash from '@shikijs/langs/shellscript' +import langJson from '@shikijs/langs/json' +import type { HighlighterCore } from 'shiki/core' + +/** Language ids (and aliases) the singleton registers; everything else renders plain. */ +const LANG_ALIASES: Record = { + typescript: 'typescript', + ts: 'typescript', + tsx: 'typescript', + javascript: 'typescript', + js: 'typescript', + shellscript: 'shellscript', + bash: 'shellscript', + sh: 'shellscript', + shell: 'shellscript', + zsh: 'shellscript', + json: 'json', + jsonc: 'json', +} + +/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */ +const cssVariablesTheme = createCssVariablesTheme({ + name: 'css-variables', + variablePrefix: '--shiki-', + fontStyle: true, +}) + +let singleton: HighlighterCore | undefined + +/** The lazily-created synchronous highlighter (one instance per document). */ +function highlighter(): HighlighterCore { + singleton ??= createHighlighterCoreSync({ + themes: [cssVariablesTheme], + langs: [langTs, langBash, langJson], + engine: createJavaScriptRegexEngine({ forgiving: true }), + }) + return singleton +} + +/** + * Highlight `code` into shiki's HTML (a single `
` tree)
+ * when `lang` maps to a registered grammar; `undefined` means the caller
+ * renders its plain fallback.
+ * @param code - the source text.
+ * @param lang - the language hint (a markdown fence info string or a fixed caller id).
+ * @returns the highlighted HTML, or `undefined` for unknown languages.
+ */
+export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
+  const resolved = lang === undefined ? undefined : LANG_ALIASES[lang.toLowerCase()]
+  if (resolved === undefined) return undefined
+  return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
+}
diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx
new file mode 100644
index 0000000000..a58248afab
--- /dev/null
+++ b/packages/client/ui-primitives/tests/code-block.spec.tsx
@@ -0,0 +1,53 @@
+// @vitest-environment jsdom
+// CodeBlock + the shiki singleton: registered grammars highlight into token
+// spans colored by --shiki-* custom properties; unknown/absent languages take
+// the identical-geometry plain arm; aliases resolve; the trailing newline is
+// display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
+// alongside the rest of the markdown family.
+
+import { describe, expect, it } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import { afterEach } from 'vitest'
+import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
+import { highlightToHtml } from '../src/markdown/highlight.ts'
+
+afterEach(cleanup)
+
+describe('highlightToHtml', () => {
+  it('highlights a registered grammar into css-variables token spans', () => {
+    const html = highlightToHtml('const x: number = 1', 'typescript')
+    expect(html).toContain('pre class="shiki css-variables"')
+    expect(html).toContain('var(--shiki-')
+  })
+
+  it.each([['ts'], ['js'], ['bash'], ['sh'], ['jsonc']])('resolves the %s alias', (alias) => {
+    expect(highlightToHtml('x', alias)).toContain('shiki')
+  })
+
+  it('returns undefined for unknown or absent languages', () => {
+    expect(highlightToHtml('x', 'cobol')).toBeUndefined()
+    expect(highlightToHtml('x', undefined)).toBeUndefined()
+  })
+})
+
+describe('CodeBlock', () => {
+  it('renders the highlighted tree for TypeScript', () => {
+    const view = render()
+    const pre = view.container.querySelector('pre.shiki')
+    expect(pre).not.toBeNull()
+    expect(pre!.textContent).toBe('const a = 1')
+    expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1)
+  })
+
+  it('renders the plain arm for an unknown language with the text verbatim', () => {
+    const view = render()
+    expect(view.container.querySelector('pre.shiki')).toBeNull()
+    expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy()
+  })
+
+  it('renders the plain arm when no language is given', () => {
+    const view = render()
+    expect(view.container.querySelector('pre.shiki')).toBeNull()
+    expect(view.getByText('plain text')).toBeTruthy()
+  })
+})
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx
index 4e1dc292d4..dcbf613005 100644
--- a/packages/client/ui-primitives/tests/markdown.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.spec.tsx
@@ -57,6 +57,8 @@ describe('MarkdownText', () => {
     expect(container.querySelector('table')?.textContent).toContain('alphabeta')
     expect(container.querySelector('hr')).not.toBeNull()
     expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
+    // The ts fence routed through the shared CodeBlock: shiki token spans present.
+    expect(container.querySelector('pre.shiki')).not.toBeNull()
     expect(container.querySelector('br')).not.toBeNull()
     expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
     expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
diff --git a/packages/client/ui-theme/src/styles/shiki.css b/packages/client/ui-theme/src/styles/shiki.css
new file mode 100644
index 0000000000..c7a3c5d272
--- /dev/null
+++ b/packages/client/ui-theme/src/styles/shiki.css
@@ -0,0 +1,31 @@
+/* Syntax-highlight token palette: the values behind shiki's css-variables
+   theme (--shiki-* custom properties emitted by the ui-primitives CodeBlock).
+   Light values on :root, dark overrides on the body attribute — the same
+   cascade as every other token sheet. Background/foreground deliberately
+   alias the markdown code-block tokens so highlighted and plain blocks agree. */
+
+:root {
+  --shiki-foreground: var(--dsw-alias-label-primary);
+  --shiki-background: var(--dsw-alias-markdown-code-block);
+  --shiki-token-constant: #1c7ed6;
+  --shiki-token-string: #2f9e44;
+  --shiki-token-comment: #868e96;
+  --shiki-token-keyword: #d6336c;
+  --shiki-token-parameter: #e8590c;
+  --shiki-token-function: #6741d9;
+  --shiki-token-string-expression: #2b8a3e;
+  --shiki-token-punctuation: #495057;
+  --shiki-token-link: #1971c2;
+}
+
+body[data-ds-dark-theme] {
+  --shiki-token-constant: #4dabf7;
+  --shiki-token-string: #69db7c;
+  --shiki-token-comment: #adb5bd;
+  --shiki-token-keyword: #faa2c1;
+  --shiki-token-parameter: #ffa94d;
+  --shiki-token-function: #b197fc;
+  --shiki-token-string-expression: #8ce99a;
+  --shiki-token-punctuation: #ced4da;
+  --shiki-token-link: #74c0fc;
+}
diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css
index 991a03bbca..b8449634eb 100644
--- a/packages/client/web/src/base.css
+++ b/packages/client/web/src/base.css
@@ -1,9 +1,10 @@
 /* Shell-owned global base: full-height mount plus the theme token sheets.
- * The three ui-theme sheets are the sole token source (--dsw-*); the shell
+ * The four ui-theme sheets are the sole token source (--dsw-*); the shell
  * links them here so tokens exist before any plugin CSS lands. */
 @import '@deepseek-ai/dsh-client-ui-theme/styles/base.css';
 @import '@deepseek-ai/dsh-client-ui-theme/styles/design-platform.css';
 @import '@deepseek-ai/dsh-client-ui-theme/styles/gradient-shadow-text.css';
+@import '@deepseek-ai/dsh-client-ui-theme/styles/shiki.css';
 
 html,
 body,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 24164d7fb5..371778cd17 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -868,6 +868,9 @@ importers:
 
   packages/client/ui-primitives:
     dependencies:
+      '@shikijs/langs':
+        specifier: ^4.3.1
+        version: 4.3.1
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
@@ -883,6 +886,9 @@ importers:
       remark-gfm:
         specifier: ^4.0.1
         version: 4.0.1
+      shiki:
+        specifier: ^4.3.1
+        version: 4.3.1
     devDependencies:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
@@ -6585,24 +6591,52 @@ packages:
   '@shikijs/core@2.5.0':
     resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==}
 
+  '@shikijs/core@4.3.1':
+    resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
+    engines: {node: '>=20'}
+
   '@shikijs/engine-javascript@2.5.0':
     resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==}
 
+  '@shikijs/engine-javascript@4.3.1':
+    resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
+    engines: {node: '>=20'}
+
   '@shikijs/engine-oniguruma@2.5.0':
     resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==}
 
+  '@shikijs/engine-oniguruma@4.3.1':
+    resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
+    engines: {node: '>=20'}
+
   '@shikijs/langs@2.5.0':
     resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==}
 
+  '@shikijs/langs@4.3.1':
+    resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
+    engines: {node: '>=20'}
+
+  '@shikijs/primitive@4.3.1':
+    resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
+    engines: {node: '>=20'}
+
   '@shikijs/themes@2.5.0':
     resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==}
 
+  '@shikijs/themes@4.3.1':
+    resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
+    engines: {node: '>=20'}
+
   '@shikijs/transformers@2.5.0':
     resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==}
 
   '@shikijs/types@2.5.0':
     resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==}
 
+  '@shikijs/types@4.3.1':
+    resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
+    engines: {node: '>=20'}
+
   '@shikijs/vscode-textmate@10.0.2':
     resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
 
@@ -8746,9 +8780,15 @@ packages:
   once@1.4.0:
     resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
 
+  oniguruma-parser@0.12.2:
+    resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
+
   oniguruma-to-es@3.1.1:
     resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
 
+  oniguruma-to-es@4.3.6:
+    resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
+
   openai@6.26.0:
     resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==}
     hasBin: true
@@ -9105,6 +9145,10 @@ packages:
   shiki@2.5.0:
     resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==}
 
+  shiki@4.3.1:
+    resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
+    engines: {node: '>=20'}
+
   side-channel-list@1.0.1:
     resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
     engines: {node: '>= 0.4'}
@@ -11222,25 +11266,58 @@ snapshots:
       '@types/hast': 3.0.5
       hast-util-to-html: 9.0.5
 
+  '@shikijs/core@4.3.1':
+    dependencies:
+      '@shikijs/primitive': 4.3.1
+      '@shikijs/types': 4.3.1
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+      hast-util-to-html: 9.0.5
+
   '@shikijs/engine-javascript@2.5.0':
     dependencies:
       '@shikijs/types': 2.5.0
       '@shikijs/vscode-textmate': 10.0.2
       oniguruma-to-es: 3.1.1
 
+  '@shikijs/engine-javascript@4.3.1':
+    dependencies:
+      '@shikijs/types': 4.3.1
+      '@shikijs/vscode-textmate': 10.0.2
+      oniguruma-to-es: 4.3.6
+
   '@shikijs/engine-oniguruma@2.5.0':
     dependencies:
       '@shikijs/types': 2.5.0
       '@shikijs/vscode-textmate': 10.0.2
 
+  '@shikijs/engine-oniguruma@4.3.1':
+    dependencies:
+      '@shikijs/types': 4.3.1
+      '@shikijs/vscode-textmate': 10.0.2
+
   '@shikijs/langs@2.5.0':
     dependencies:
       '@shikijs/types': 2.5.0
 
+  '@shikijs/langs@4.3.1':
+    dependencies:
+      '@shikijs/types': 4.3.1
+
+  '@shikijs/primitive@4.3.1':
+    dependencies:
+      '@shikijs/types': 4.3.1
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+
   '@shikijs/themes@2.5.0':
     dependencies:
       '@shikijs/types': 2.5.0
 
+  '@shikijs/themes@4.3.1':
+    dependencies:
+      '@shikijs/types': 4.3.1
+
   '@shikijs/transformers@2.5.0':
     dependencies:
       '@shikijs/core': 2.5.0
@@ -11251,6 +11328,11 @@ snapshots:
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.5
 
+  '@shikijs/types@4.3.1':
+    dependencies:
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+
   '@shikijs/vscode-textmate@10.0.2': {}
 
   '@smithy/core@3.24.7':
@@ -13813,12 +13895,20 @@ snapshots:
     dependencies:
       wrappy: 1.0.2
 
+  oniguruma-parser@0.12.2: {}
+
   oniguruma-to-es@3.1.1:
     dependencies:
       emoji-regex-xs: 1.0.0
       regex: 6.1.0
       regex-recursion: 6.0.2
 
+  oniguruma-to-es@4.3.6:
+    dependencies:
+      oniguruma-parser: 0.12.2
+      regex: 6.1.0
+      regex-recursion: 6.0.2
+
   openai@6.26.0(ws@8.21.0)(zod@4.4.3):
     optionalDependencies:
       ws: 8.21.0
@@ -14319,6 +14409,17 @@ snapshots:
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.5
 
+  shiki@4.3.1:
+    dependencies:
+      '@shikijs/core': 4.3.1
+      '@shikijs/engine-javascript': 4.3.1
+      '@shikijs/engine-oniguruma': 4.3.1
+      '@shikijs/langs': 4.3.1
+      '@shikijs/themes': 4.3.1
+      '@shikijs/types': 4.3.1
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+
   side-channel-list@1.0.1:
     dependencies:
       es-errors: 1.3.0

From c57af8fa36929024e85b1dd9ee57f4e79057d585 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 10:06:36 +0800
Subject: [PATCH 03/23] docs(notes): add Chinese pair for the shiki
 highlighting note

---
 ...26-web-syntax-highlighting-shiki.i18n.yaml |  6 ++++
 ...-07-26-web-syntax-highlighting-shiki.zh.md | 32 +++++++++++++++++++
 2 files changed, 38 insertions(+)
 create mode 100644 .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
 create mode 100644 .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md

diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
new file mode 100644
index 0000000000..c53eb89293
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.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-web-syntax-highlighting-shiki.md: 79ad2153b8883fda92205dada300fd194834129b
+2026-07-26-web-syntax-highlighting-shiki.zh.md: 81d5c8bea8484ee54c4795308afae2ca66231af7
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
new file mode 100644
index 0000000000..81d5c8bea8
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
@@ -0,0 +1,32 @@
+# Agent Note:web client 的语法高亮——同步细粒度的 shiki
+
+Status: implemented
+
+[English](2026-07-26-web-syntax-highlighting-shiki.md) | 中文
+
+> 范围:web client 唯一的一套语法高亮体系——依赖裁决、单例形态、token 表契约与各消费表面。本篇是 Code Mode UI 堆叠 PR(Pull Request)链的第五个 PR;[chat 子调用行 Agent Note](../feature/2026-07-26-code-mode-chat-subcall-rows.md)交付了 `run_code` 程序正文,而本体系存在的意义正是让它可读。样式的基本规则归 [Web 样式体系裁决](2026-07-19-web-styling-system.md)所有。
+
+## 问题
+
+client 过去把每一处代码表面——assistant 正文里的 markdown 围栏代码块、`run_code` 程序正文、details 面板的参数——一律渲染成不带高亮的等宽纯文本。本堆叠 PR 链的主要载荷是模型撰写的 TypeScript;未经高亮的程序扫读起来明显更吃力,而仓库已经在自家 VitePress 站点上交付经 shiki 高亮的代码,于是 web 应用成了唯一不带语法高亮的代码渲染表面。
+
+## 决策
+
+**采用同步细粒度形态的 shiki,作为 `ui-primitives` 里的一个单例,主题化完全经由 CSS 自定义属性完成。**
+
+- **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。
+- **单例**:`ui-primitives/src/markdown/highlight.ts` 按每个 document 惰性创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
+- **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),经壳的 `base.css` 引入链导入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
+- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法造成的误高亮会多于帮助。
+
+## 曾考虑的替代方案
+
+**`rehype-highlight`/lowlight。** 屈居次选:天然同步,bundle 约为三分之一,但正则语法在 TypeScript 上的保真度肉眼可见地更差,而且仓库将从此同时运行两套高亮体系(站点用 shiki、应用用 highlight.js)、维护两套主题化词汇。
+
+**完整的 `shiki` bundle,或 oniguruma WASM 引擎。** 否决:完整 bundle 会带上每一种语法和主题;WASM 需要异步加载,而这正是同步的 client 启动刻意规避的。细粒度 core 加三种语法,让成本与实际用量成正比。
+
+**在 worker 中高亮/异步高亮。** 否决:载荷都很小(程序、围栏代码块、参数);同步 JS 引擎微秒级就能把它们 token 化,而异步会引入一段未高亮代码的闪现,外加渲染机制的扰动,却没有任何实测得出的需要。
+
+## 后果
+
+所有消费方共用同一个代码表面——未来的新表面导入 `CodeBlock` 即继承高亮、主题化与纯文本回退。bundle 的增量是 shiki core 加三种语法(在 `ui-primitives` 中一次性支付)。token 颜色是第一张 `--shiki-*` 表;注册别名覆写的主题包扩展它们的方式与扩展任何其他 token 无异。jsdom spec 锁定 token span 结构、别名解析、两条回退分支与围栏路由;既有的已构建 bundle 快照和浏览器 e2e 覆盖组装后的路径。

From 63cd1b58348403cbd36603b5f64a50ddf134db6c Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 10:19:43 +0800
Subject: [PATCH 04/23] docs(notes): refine the shiki note's Chinese pair

---
 .../2026-07-26-web-syntax-highlighting-shiki.i18n.yaml    | 2 +-
 .../2026-07-26-web-syntax-highlighting-shiki.zh.md        | 8 ++++----
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
index c53eb89293..d0e217941b 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
@@ -3,4 +3,4 @@
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
 2026-07-26-web-syntax-highlighting-shiki.md: 79ad2153b8883fda92205dada300fd194834129b
-2026-07-26-web-syntax-highlighting-shiki.zh.md: 81d5c8bea8484ee54c4795308afae2ca66231af7
+2026-07-26-web-syntax-highlighting-shiki.zh.md: 4cb3f0ceadebc4837108463c149262bf8e36f93d
diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
index 81d5c8bea8..4cb3f0cead 100644
--- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
@@ -15,13 +15,13 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围
 **采用同步细粒度形态的 shiki,作为 `ui-primitives` 里的一个单例,主题化完全经由 CSS 自定义属性完成。**
 
 - **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。
-- **单例**:`ui-primitives/src/markdown/highlight.ts` 按每个 document 惰性创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
-- **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),经壳的 `base.css` 引入链导入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
-- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法造成的误高亮会多于帮助。
+- **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 惰性创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
+- **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
+- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。
 
 ## 曾考虑的替代方案
 
-**`rehype-highlight`/lowlight。** 屈居次选:天然同步,bundle 约为三分之一,但正则语法在 TypeScript 上的保真度肉眼可见地更差,而且仓库将从此同时运行两套高亮体系(站点用 shiki、应用用 highlight.js)、维护两套主题化词汇。
+**`rehype-highlight`/lowlight。** 屈居次选:天然同步,bundle 体积约为三分之一,但基于正则的语法在 TypeScript 上的保真度肉眼可见地更差,而且仓库将从此同时运行两套高亮体系(站点用 shiki、应用用 highlight.js)、维护两套主题化词汇。
 
 **完整的 `shiki` bundle,或 oniguruma WASM 引擎。** 否决:完整 bundle 会带上每一种语法和主题;WASM 需要异步加载,而这正是同步的 client 启动刻意规避的。细粒度 core 加三种语法,让成本与实际用量成正比。
 

From 7b58346b3c3d789fb1a46ea2c6b42f35e491b062 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 10:40:03 +0800
Subject: [PATCH 05/23] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20log?=
 =?UTF-8?q?=20shaping=20off=20the=20program-facing=20path?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ds-review-bot finding: awaiting shapeDispatchLog before resolving the
binding let a slow spill backend delay the program and occupy a
dispatch slot. The settle now resolves the program immediately; the
shaped append runs as tracked side work (logWork) drained at run
settlement, so every tool/code-dispatch event still lands inside the
open turn. New spec pins the contract: with a hung spill backend the
second dispatch starts and the program completes both calls, and both
settle events land once released.
---
 .../spill-policy/tests/spill-policy.spec.ts   | 60 +++++++++++++++++++
 1 file changed, 60 insertions(+)

diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts
index 33a9aa7cee..9dc607be5d 100644
--- a/packages/spill/spill-policy/tests/spill-policy.spec.ts
+++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts
@@ -290,6 +290,66 @@ describe('the durable dispatch-log arm', () => {
     expect(spill.saves.filter(entry => entry.source.label === 'dispatch')).toHaveLength(0)
   })
 
+  it('a slow spill backend never delays the program value or a later dispatch slot', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt)
+    await ctx.plugin(ToolRegistry, { mode: 'code' })
+    await ctx.plugin(StubStore)
+    await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 })
+    await ctx.plugin(WorkerCodeRuntime, {})
+    // A spill backend that hangs until released.
+    let releaseSave!: () => void
+    const gate = new Promise((resolve) => { releaseSave = resolve })
+    const store = ctx.spillStore as StubStore
+    const realSave = store.saveText.bind(store)
+    store.saveText = async (input) => {
+      await gate
+      return realSave(input)
+    }
+    const events: { type: string; data: unknown }[] = []
+    const agent = {
+      session: {
+        header: { id: SessionId('dispatch-slow-spill'), cwd: '/workspace' },
+        append: (type: string, data: unknown) => { events.push({ type, data }) },
+      },
+    }
+    ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000)))
+    ctx.tools.register(textTool('small_read', 'tiny'))
+    let smallAfterHuge = false
+    const runPromise = ctx.tools.execute({
+      signal: testToolSignal,
+      callId: CallId('parent-3'),
+      name: 'run_code',
+      arguments: {
+        // The program takes BOTH values while the spill backend hangs: the
+        // huge read's binding resolves immediately (its logged copy is side
+        // work), so the small read proceeds without waiting.
+        code: 'const big = await tools.huge_read({});\nconst small = await tools.small_read({});\nreturn big[0].text.length + small[0].text.length',
+        description: 'Prove log shaping is off the program path',
+      },
+      agent: agent as never,
+    }).then((result) => {
+      return result
+    })
+    // The run cannot COMPLETE while the settle append is gated (drain waits
+    // for logWork), but the program itself already ran both calls; release
+    // the backend and observe the settle events land inside the turn.
+    await vi.waitFor(() => {
+      // The second dispatch STARTED while the first one's spill hung.
+      smallAfterHuge = events.some(event => event.type === 'tool/code-dispatch-start'
+        && (event.data as { name: string }).name === 'small_read')
+      if (!smallAfterHuge) throw new Error('small_read not started yet')
+    })
+    releaseSave()
+    const result = await runPromise
+    expect(result.isError).toBe(false)
+    if (result.isError) throw new Error('expected success')
+    expect(result.value).toMatchObject({ result: 2_004 })
+    const settles = events.filter(event => event.type === 'tool/code-dispatch')
+    expect(settles).toHaveLength(2)
+    expect(smallAfterHuge).toBe(true)
+  })
+
   it('a saveText failure keeps the complete content in the durable log (best-effort)', async () => {
     const ctx = new Context()
     await ctx.plugin(SystemPrompt)

From 104e83109fd26bbe60206f349167f0245a611274 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 26 Jul 2026 10:43:52 +0800
Subject: [PATCH 06/23] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20pla?=
 =?UTF-8?q?in=20fences=20while=20streaming?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

ds-review-bot finding: a growing fence retokenized on every chunk
(quadratic main-thread work). MarkdownText gains a streaming flag —
the streaming partial renders fences through the plain arm and the
finalize swap highlights once; AssistantMarkdown threads its existing
flag. (The zh Agent Note pair the review also flagged landed earlier
on this branch.) New spec pins plain-while-streaming and
highlighted-after-finalize.
---
 .../src/client/chat/AssistantMarkdown.tsx     |  2 +-
 .../src/markdown/MarkdownText.tsx             | 44 ++++++++++++-------
 .../ui-primitives/tests/markdown.spec.tsx     | 10 +++++
 3 files changed, 38 insertions(+), 18 deletions(-)

diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
index 90eb3e3bee..0e91afcc07 100644
--- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
+++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
@@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
     
{blocks.map((block, i) => { switch (block.kind) { - case 'text': return + case 'text': return case 'reasoning': return // Tool-call heads render as tool rows in the chat view's grouping pass. case 'tool-call': return null diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index f74e939246..79ebc5f1b2 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -24,7 +24,9 @@ function sanitizeUrl(url: string): string { const safeUrl: UrlTransform = url => sanitizeUrl(url) -const components: Components = { +/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ +function buildComponents(streaming: boolean): Components { + return { a: ({ href = '', children }) => { const safeHref = sanitizeUrl(href) if (safeHref === '') return <>{children} @@ -44,32 +46,40 @@ const components: Components = { {children}
), - // Fenced blocks route through the shared CodeBlock (shiki for registered - // grammars, identical-geometry plain fallback for unknown/absent languages); - // inline code keeps the default path (the :not(pre) rule styles it). - pre: ({ children }) => { - const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined - const raw = child?.props.children - const text = typeof raw === 'string' ? raw : Array.isArray(raw) && typeof raw[0] === 'string' ? raw[0] : undefined - // A fence whose content isn't one plain string (never produced by the - // markdown pipeline) keeps the stock
 rather than guessing.
-    if (text === undefined) return 
{children}
- const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1] - return - }, + // Fenced blocks route through the shared CodeBlock (shiki for registered + // grammars, identical-geometry plain fallback for unknown/absent + // languages); inline code keeps the default path (the :not(pre) + // rule styles it). While the message streams, the fence renders the + // plain arm — retokenizing a growing fence on every chunk is quadratic + // main-thread work; the finalize swap highlights it once. + pre: ({ children }) => { + const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined + const raw = child?.props.children + const text = typeof raw === 'string' ? raw : Array.isArray(raw) && typeof raw[0] === 'string' ? raw[0] : undefined + // A fence whose content isn't one plain string (never produced by the + // markdown pipeline) keeps the stock
 rather than guessing.
+      if (text === undefined) return 
{children}
+ const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1] + return + }, + } } +const staticComponents = buildComponents(false) +const streamingComponents = buildComponents(true) + /** * Render untrusted assistant-authored Markdown as semantic React elements. - * @param props - Markdown source text preserved by the session projection. + * @param props - Markdown source text preserved by the session projection; + * `streaming` renders fences plain (highlighting lands on the finalize swap). * @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled. */ -export function MarkdownText({ text }: { text: string }) { +export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) { return (
{text} diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index dcbf613005..1bd629a7d0 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -64,6 +64,16 @@ describe('MarkdownText', () => { expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() }) + it('streaming renders fences plain; the finalize swap highlights them', () => { + const fence = '```ts\nconst answer = 42\n```' + const live = render() + expect(live.container.querySelector('pre.shiki')).toBeNull() + expect(live.container.querySelector('pre code')?.textContent).toContain('const answer = 42') + live.unmount() + const done = render() + expect(done.container.querySelector('pre.shiki')).not.toBeNull() + }) + it('neutralizes raw HTML, unsafe or relative links, and remote images', () => { const markdown = [ '', 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 07/23] 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 d5bf00b3007ffa48311fc47c7258c498b9f72d28 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:56:57 +0800 Subject: [PATCH 08/23] docs: regenerate catalogs and register CodeDispatchLog type-equiv on the stacked tree The static CI gates run per-branch on the merged tree: regen the cordis catalog/api, config, persistence, and doc-graph outputs that PR3/PR4's source changes shifted, and add the CodeDispatchLog manifest entries for the tools.md pair's new type-equiv block. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 28 +++++++++++++++++-- docs/cordis-catalog/services.md | 14 ++++++++-- docs/persistence-catalog.md | 4 +-- .../cordis/tool-cordis/src/api-catalog.ts | 15 ++++++++++ scripts/type-equiv.manifest.json | 10 +++++++ 6 files changed, 66 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eeaed0302a..058eb65eeb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1718,7 +1718,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:562`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:564`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 1948c17f97..4c97fdc72d 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -842,7 +842,31 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:143`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:156`](../../packages/core/tools/src/index.ts) + +### `tools/code-dispatch-log` — waterfall + +Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the unshaped content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + +```ts cordis-catalog +/** + * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before + * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the unshaped content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ +'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise +``` + +Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) + +Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -927,7 +951,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:133`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:146`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0903f35b1c..6facbbb9b3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1830,6 +1830,16 @@ schemas(scope?: ScopeKey): ToolSchema[] */ executionMode(exec: ToolExecutionInput): ToolExecutionMode +/** + * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch + * and return the content the bridge should log on `tool/code-dispatch`. + * Contained: a throwing listener falls back to the unshaped content — log + * shaping must never fail the dispatch or lose the settle event. + * @param dispatch - the sub-dispatch identity and its default logged content. + * @returns the (possibly reshaped) content for the durable event. + */ +async shapeDispatchLog(dispatch: CodeDispatchLog): Promise + /** * Execute through pre-policy, guards, around-dispatch, post-policy, * definition-owned content finalization, and final notification. Tool and @@ -1847,9 +1857,9 @@ executionMode(exec: ToolExecutionInput): ToolExecutionMode async execute(exec: ToolExecutionInput): Promise ``` -Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) +Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:642`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:677`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 14b323704c..e7790fa76f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -474,7 +474,7 @@ Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/tools/src/code-mode.ts:48`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/code-mode.ts) #### `tool/code-dispatch-start` — log-only @@ -497,7 +497,7 @@ Source: [`packages/core/tools/src/code-mode.ts:48`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/tools/src/code-mode.ts:32`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/code-mode.ts) #### `tool/result` — surface diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d834d3ee21..37df1a65d3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -864,6 +864,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'executionMode(exec: ToolExecutionInput): ToolExecutionMode', jsDoc: '/**\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 */', }, + { + signature: 'async shapeDispatchLog(dispatch: CodeDispatchLog): Promise', + jsDoc: '/**\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 */', + }, { signature: 'async execute(exec: ToolExecutionInput): Promise', jsDoc: '/**\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 */', @@ -1222,6 +1226,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A tool was registered or unregistered, or a scoped restriction changed\n * (the available tool set changed — possibly for one scope only). An\n * UNFILTERED registry-subject notification, deliberately not scope-filtered\n * dispatch: a global change concerns every agent\'s next assembly, so a\n * scoped listener subscribing here sees every change, not just its own\n * scope\'s.\n * @mode emit\n */', summary: 'A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only).', }, + { + name: 'tools/code-dispatch-log', + mode: 'waterfall', + signature: '\'tools/code-dispatch-log\'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise', + jsDoc: '/**\n * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before\n * the bridge appends its `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the unshaped content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */', + summary: 'Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', + }, { name: 'tools/execute', mode: 'waterfall', @@ -1432,6 +1443,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeBindingNamespace', declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n errorClass?: CodeBindingErrorClass;\n}', }, + { + name: 'CodeDispatchLog', + declaration: '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}', + }, { name: 'CodeJsonValue', declaration: 'export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | {\n [key: string]: CodeJsonValue;\n};', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0310306bfa..506b01f5ec 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -609,6 +609,11 @@ "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "CodeDispatchLog", + "source": "packages/core/tools/src/index.ts" + }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRunContext", @@ -1747,6 +1752,11 @@ "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "CodeDispatchLog", + "source": "packages/core/tools/src/index.ts" + }, { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolRunContext", 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 09/23] 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 2675ab49ef932e360943c202a6c57cd6623ff8c3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:12:22 +0800 Subject: [PATCH 10/23] test(snapshots): refresh cordis-inspect-jsdoc for the regenerated api catalog This branch's gen-cordis-api regen (the static-gate fix) changed the registry JSDoc the scenario pins. --- .../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 ded5d01c6ceb6eca7be38fdf2b512bd84e38c79e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:40:02 +0800 Subject: [PATCH 11/23] docs: regenerate catalogs and register CodeDispatchLog type-equiv on this tree The static CI gates run per-branch on the merged tree: the cordis catalog/api, config-catalog, and type-equiv manifest updates for the tools/code-dispatch-log waterfall and CodeDispatchLog payload previously landed only on the shiki branch (09734f23b); this branch's own tree needs the same regenerated outputs and manifest entries. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 28 +++++++++++++++++-- docs/cordis-catalog/services.md | 14 ++++++++-- .../cordis/tool-cordis/src/api-catalog.ts | 15 ++++++++++ scripts/type-equiv.manifest.json | 10 +++++++ 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eeaed0302a..058eb65eeb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1718,7 +1718,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:562`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:564`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 1948c17f97..4c97fdc72d 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -842,7 +842,31 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:143`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:156`](../../packages/core/tools/src/index.ts) + +### `tools/code-dispatch-log` — waterfall + +Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the unshaped content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + +```ts cordis-catalog +/** + * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before + * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the unshaped content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ +'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise +``` + +Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) + +Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -927,7 +951,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:133`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:146`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0903f35b1c..6facbbb9b3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1830,6 +1830,16 @@ schemas(scope?: ScopeKey): ToolSchema[] */ executionMode(exec: ToolExecutionInput): ToolExecutionMode +/** + * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch + * and return the content the bridge should log on `tool/code-dispatch`. + * Contained: a throwing listener falls back to the unshaped content — log + * shaping must never fail the dispatch or lose the settle event. + * @param dispatch - the sub-dispatch identity and its default logged content. + * @returns the (possibly reshaped) content for the durable event. + */ +async shapeDispatchLog(dispatch: CodeDispatchLog): Promise + /** * Execute through pre-policy, guards, around-dispatch, post-policy, * definition-owned content finalization, and final notification. Tool and @@ -1847,9 +1857,9 @@ executionMode(exec: ToolExecutionInput): ToolExecutionMode async execute(exec: ToolExecutionInput): Promise ``` -Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) +Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:642`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:677`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d834d3ee21..37df1a65d3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -864,6 +864,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'executionMode(exec: ToolExecutionInput): ToolExecutionMode', jsDoc: '/**\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 */', }, + { + signature: 'async shapeDispatchLog(dispatch: CodeDispatchLog): Promise', + jsDoc: '/**\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 */', + }, { signature: 'async execute(exec: ToolExecutionInput): Promise', jsDoc: '/**\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 */', @@ -1222,6 +1226,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A tool was registered or unregistered, or a scoped restriction changed\n * (the available tool set changed — possibly for one scope only). An\n * UNFILTERED registry-subject notification, deliberately not scope-filtered\n * dispatch: a global change concerns every agent\'s next assembly, so a\n * scoped listener subscribing here sees every change, not just its own\n * scope\'s.\n * @mode emit\n */', summary: 'A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only).', }, + { + name: 'tools/code-dispatch-log', + mode: 'waterfall', + signature: '\'tools/code-dispatch-log\'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise', + jsDoc: '/**\n * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before\n * the bridge appends its `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the unshaped content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */', + summary: 'Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', + }, { name: 'tools/execute', mode: 'waterfall', @@ -1432,6 +1443,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeBindingNamespace', declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n errorClass?: CodeBindingErrorClass;\n}', }, + { + name: 'CodeDispatchLog', + declaration: '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}', + }, { name: 'CodeJsonValue', declaration: 'export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | {\n [key: string]: CodeJsonValue;\n};', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0310306bfa..506b01f5ec 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -609,6 +609,11 @@ "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "CodeDispatchLog", + "source": "packages/core/tools/src/index.ts" + }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRunContext", @@ -1747,6 +1752,11 @@ "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "CodeDispatchLog", + "source": "packages/core/tools/src/index.ts" + }, { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolRunContext", 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 12/23] 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') }) }) From 3b63bcbeeccd92c6cef8bf8a4d03defd86450524 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:32:22 +0800 Subject: [PATCH 13/23] test: cover the dispatch-log seam's contained-failure and decline arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's full-tree coverage flagged three untaken paths this PR introduced: - shapeDispatchLog's catch (a throwing tools/code-dispatch-log listener must be contained — the settle event logs the unshaped content); - the spill listener's flatten-decline arm (non-text sub-result content passes through unchanged); - the generated scope-key extractor row for tools/code-dispatch-log (registered in the scope invariant matrix like the other tools events). --- packages/core/scope/tests/invariant.spec.ts | 1 + packages/core/tools/tests/code-mode.spec.ts | 15 +++++++++++++++ .../spill-policy/tests/spill-policy.spec.ts | 19 ++++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index ca0841165b..e2ad1447e0 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -63,6 +63,7 @@ describe('scoped-dispatch invariants', () => { ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], ['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]], ['system-prompt/assemble', [[], { scope: agent }]], + ['tools/code-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index e227f07544..b77bef0fec 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -694,6 +694,21 @@ describe('the run_code dispatch bridge', () => { expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' }) }) + it('a throwing tools/code-dispatch-log listener is contained: the unshaped content is logged', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + registerEcho(ctx) + ctx.on('tools/code-dispatch-log', () => { throw new Error('shaper exploded') }) + const { agent, events } = fakeAgent() + runtime.behavior = async (request) => { + const value = await request.bindings[0]!.functions.echo!({ value: 'x' }) + return { logs: [], value: value as string } + } + const result = await runCode(ctx, 'program', { agent }) + expect(result.isError).toBe(false) + const settle = events.find(event => event.type === 'tool/code-dispatch') + expect(settle?.data).toMatchObject({ name: 'echo', isError: false, content: [{ type: 'text', text: 'echo:x' }] }) + }) + it('a throwing tools/pre-execute listener settles the sub-call without post-execute', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const calls = registerEcho(ctx) diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 9dc607be5d..f132c0f98a 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -16,6 +16,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import type { PostToolDecision, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' @@ -232,7 +233,7 @@ describe('read skip', () => { describe('the durable dispatch-log arm', () => { /** Boot code mode + the policy + the worker runtime; run one program via the real bridge. */ - async function runCodeWith(program: string, maxInlineBytes: number) { + async function runCodeWith(program: string, maxInlineBytes: number, extraTools: ToolDefinition[] = []) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry, { mode: 'code' }) @@ -248,6 +249,7 @@ describe('the durable dispatch-log arm', () => { } ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000))) ctx.tools.register(textTool('small_read', 'tiny')) + for (const tool of extraTools) ctx.tools.register(tool) const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('parent-1'), @@ -281,6 +283,21 @@ describe('the durable dispatch-log arm', () => { expect(save?.content).toBe('H'.repeat(2_000)) }) + it('leaves a non-text sub-result log unchanged (flatten declines)', async () => { + const { events, spill } = await runCodeWith( + 'return await tools.mixed_read({})', 5, [defineContentToolFixture({ + name: 'mixed_read', + description: 'mixed_read', + parameters: {}, + async execute(): Promise { + return [{ type: 'text', text: 'x'.repeat(100) }, { type: 'reasoning', text: 'why' }] + }, + })]) + const settle = events.find(event => event.type === 'tool/code-dispatch') + expect((settle!.data as { content: unknown[] }).content).toHaveLength(2) + expect(spill.saves.filter(entry => entry.source.label === 'dispatch')).toHaveLength(0) + }) + it('leaves a within-cap sub-result log untouched and saves nothing for it', async () => { const { events, spill } = await runCodeWith( 'return await tools.small_read({})', 200) From 28b617dd738b0c3c5ec56359a2a8546285253212 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:41:23 +0800 Subject: [PATCH 14/23] test(ui-primitives): cover the fence pre-routing arms; drop the unreachable array probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI coverage flagged MarkdownText's pre route: the array-element probe (raw[0]) and the mixed-content fallbacks were unreachable — the markdown pipeline hands pre one code element whose children are one string (or none, for an empty fence). Simplify to the string check, annotate the isValidElement guard as representation-change armor, and pin both live arms: the empty fence keeps the stock
, a language-less fence renders
the plain CodeBlock arm.
---
 .../client/ui-primitives/src/markdown/MarkdownText.tsx | 10 +++++-----
 packages/client/ui-primitives/tests/markdown.spec.tsx  |  9 +++++++++
 2 files changed, 14 insertions(+), 5 deletions(-)

diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
index 79ebc5f1b2..775978a275 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
@@ -53,14 +53,14 @@ function buildComponents(streaming: boolean): Components {
     // plain arm — retokenizing a growing fence on every chunk is quadratic
     // main-thread work; the finalize swap highlights it once.
     pre: ({ children }) => {
+      /* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */
       const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
       const raw = child?.props.children
-      const text = typeof raw === 'string' ? raw : Array.isArray(raw) && typeof raw[0] === 'string' ? raw[0] : undefined
-      // A fence whose content isn't one plain string (never produced by the
-      // markdown pipeline) keeps the stock 
 rather than guessing.
-      if (text === undefined) return 
{children}
+ // A fence whose content isn't one plain string (e.g. an empty fence) + // keeps the stock
 rather than guessing.
+      if (typeof raw !== 'string') return 
{children}
const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1] - return + return }, } } diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 1bd629a7d0..00de9683ff 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -64,6 +64,15 @@ describe('MarkdownText', () => { expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() }) + it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => { + const empty = render() + expect(empty.container.querySelector('pre')?.outerHTML).toBe('
') + + const plain = render() + expect(plain.container.querySelector('pre.shiki')).toBeNull() + expect(plain.container.querySelector('pre code')?.textContent).toContain('no language here') + }) + it('streaming renders fences plain; the finalize swap highlights them', () => { const fence = '```ts\nconst answer = 42\n```' const live = render() From c3c10820baee56dc3bbc8f0cf2ba9e28fd51c5ee Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:29:09 +0800 Subject: [PATCH 15/23] fix(tools): bound the shaped-append side channel; total error containment; recorded spill snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responding to ds-review-bot round 2 on #661: - logWork is bounded: past maxParallelSubCalls pending shaped-append tasks the ordered commit lane holds (Promise.race drains one), so a slow spill backend backpressures the run instead of accumulating unbounded pending I/O and retained results. Tasks self-remove on settlement; run settlement still drains every task inside the open turn. New spill test drives three oversized reads against a hung backend at cap 1 and proves the third dispatch cannot start until a save drains. - shapeDispatchLog's catch uses errorMessage() (total), so a thrown value with a throwing toString cannot escape the containment and lose the settle event. - CodeDispatchLog.content documented as the RENDERED result projection (native tool/result vocabulary), not what the program received — the program gets the structured value; doc pair + type-equiv re-synced. - New RECORDED tui-agent snapshot scenario code-mode-dispatch-spill: the real Loader-visible composition (worker runtime + spill-local + policy) drives an oversized bash sub-call end-to-end; replay proves the durable dispatch copy is bounded to preview + locator while the program value stays whole (the outer result carries just the line count). Agent Note updated (both languages). --- ...26-07-26-code-dispatch-log-spill.i18n.yaml | 4 +- .../2026-07-26-code-dispatch-log-spill.md | 2 +- .../2026-07-26-code-dispatch-log-spill.zh.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/tools.i18n.yaml | 4 +- docs/core-data-structures/tools.md | 6 +- docs/core-data-structures/tools.zh.md | 6 +- .../code-mode-dispatch-spill/session.jsonl | 194 ++++++++++++++++++ .../terminal.expected.txt | 65 ++++++ examples/tui-agent/tests/tui.snapshot.ts | 32 +++ packages/core/tools/src/code-mode.ts | 20 +- packages/core/tools/src/index.ts | 8 +- .../spill-policy/tests/spill-policy.spec.ts | 60 ++++++ 14 files changed, 384 insertions(+), 23 deletions(-) create mode 100644 examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl create mode 100644 examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml index f00ecd5d2a..b00bff1000 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.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-dispatch-log-spill.md: 2668c195a43ae1f6011c09413338a23caf75401e -2026-07-26-code-dispatch-log-spill.zh.md: e084ae80d7fed864c7f296b1fd6db713acf7a2b0 +2026-07-26-code-dispatch-log-spill.md: 65af7808c493867cb13042a4f169ffdf05eb4538 +2026-07-26-code-dispatch-log-spill.zh.md: e1293e62f9de9860300428c5c0d25c5404dc76f9 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md index 2668c195a4..65af7808c4 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md @@ -14,7 +14,7 @@ Since the full-content dispatch logging landed, a `run_code` program that reads **A log-shaping waterfall on the registry, and the spill policy as its first listener.** -- **Seam**: `tools/code-dispatch-log` — a scope-filtered waterfall the bridge runs (via `registry.shapeDispatchLog`, contained: a throwing listener falls back to the unshaped content) over each settled sub-dispatch before appending `tool/code-dispatch`. The payload (`CodeDispatchLog`) carries the outer execution, the hoisted `agent` routing key, the sub-call identity, and the default content. Only the durable copy is shapeable — the program already received the complete value across the worker boundary, and the model sees neither. +- **Seam**: `tools/code-dispatch-log` — a scope-filtered waterfall the bridge runs (via `registry.shapeDispatchLog`, contained: a throwing listener falls back to the unshaped content, with total error formatting so a hostile thrown value cannot escape the containment) over each settled sub-dispatch before appending `tool/code-dispatch`. The payload (`CodeDispatchLog`) carries the outer execution, the hoisted `agent` routing key, the sub-call identity, and the default content — the RENDERED result projection a native `tool/result` would carry (the program itself received the structured `value`). Only the durable copy is shapeable; the model sees neither. Shaping runs OFF the program path as tracked side work, but bounded: past `maxParallelSubCalls` pending log tasks the ordered commit lane holds, so a slow spill backend backpressures the run instead of accumulating unbounded pending I/O; run settlement still drains every task inside the open turn. - **Policy**: `dsh-spill-policy` registers a second arm on the new seam sharing the exact replacement pipeline of its model-facing arm (same `maxInlineBytes` cap, same preview + locator + within-cap invariant, same best-effort fallbacks), with the artifact labeled `dispatch` under the sub-call id. UIs and replay read the full text through the spill artifact exactly as they do for spilled native results, so the native-parity rendering story survives bounding. - **One deliberate asymmetry**: the model-facing arm skips `read` (the `read → spill → read again` loop); the dispatch-log arm bounds `read` sub-calls too — a log copy is not model context, so the loop cannot happen, and `read` is precisely the tool that produces huge logs. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md index e084ae80d7..e1293e62f9 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md @@ -14,7 +14,7 @@ Status: implemented **在注册表上增设一个日志整形 waterfall(瀑布式事件),spill 策略作为其第一个监听器。** -- **Seam**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由 `registry.shapeDispatchLog`,且故障被兜住:监听器抛出异常时回退到未整形的内容)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容。可整形的只有持久副本:程序已经跨 worker 边界收到了完整的值,而模型两者都看不到。 +- **Seam**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由 `registry.shapeDispatchLog`,且故障被兜住:监听器抛出异常时回退到未整形的内容,并用全防御的错误格式化确保恶意抛出值无法逃出兜底)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容——即原生 `tool/result` 所载的渲染后结果投影(程序本身收到的是结构化 `value`)。可整形的只有持久副本;模型两者都看不到。整形作为被跟踪的旁路工作在程序路径之外运行,但有界:待处理日志任务超过 `maxParallelSubCalls` 时有序提交车道会暂停,因此慢速 spill 后端会对整个 run 施加背压,而不是无限累积待完成 I/O;run 结算仍会在开放轮次内排空全部任务。 - **策略**:`dsh-spill-policy` 在新 seam 上注册第二个分支,与其面向模型的分支共用一模一样的替换流水线(同样的 `maxInlineBytes` 上限、同样的预览 + 定位符 + 不超上限不变式、同样的尽力而为回退),产物以 `dispatch` 为标签,记在子调用 id 名下。UI 与回放通过 spill 产物读取全文,方式与读取被 spill 的原生结果完全相同,因此与原生同等保真的渲染在施加边界之后依然成立。 - **一处有意的不对称**:面向模型的分支跳过 `read`(避免 `read → spill → read again` 循环);分发日志分支则连 `read` 子调用也施加边界:日志副本不是模型上下文,该循环因此不可能发生,而 `read` 恰恰是会产生巨大日志的那个工具。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 058eb65eeb..cf0b45c1ff 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1718,7 +1718,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:564`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:566`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6facbbb9b3..918382c5fe 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1859,7 +1859,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:677`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:688`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 19c6cb4612..7c82b890b7 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.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 -tools.md: 389c54bf625f762257a4830ed915d526230090ab -tools.zh.md: fba3453fa91be2544eb3ab94ca67aaf0452958b2 +tools.md: 250e869397f8ecb128d5b644ff7506376d0657c6 +tools.zh.md: 96fc9d3eeda0240e195beb11bea088d5606d4757 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 389c54bf62..250e869397 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -238,8 +238,10 @@ Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/ * One settled `run_code` sub-dispatch about to be logged, as seen by the * `tools/code-dispatch-log` waterfall: the parent execution (session owner, * outer call identity), the sub-call identity, and the outcome whose durable - * copy a listener may reshape. The complete `content` is what the program - * already received; only the `tool/code-dispatch` event's copy changes. + * copy a listener may reshape. `content` is the RENDERED result projection + * (what a native `tool/result` would carry) — the program itself received + * the structured `value` (or just the error message on failure); only the + * `tool/code-dispatch` event's copy changes. */ interface CodeDispatchLog { /** The outer `run_code` execution. */ diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index fba3453fa9..96fc9d3eed 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -238,8 +238,10 @@ Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code- * One settled `run_code` sub-dispatch about to be logged, as seen by the * `tools/code-dispatch-log` waterfall: the parent execution (session owner, * outer call identity), the sub-call identity, and the outcome whose durable - * copy a listener may reshape. The complete `content` is what the program - * already received; only the `tool/code-dispatch` event's copy changes. + * copy a listener may reshape. `content` is the RENDERED result projection + * (what a native `tool/result` would carry) — the program itself received + * the structured `value` (or just the error message on failure); only the + * `tool/code-dispatch` event's copy changes. */ interface CodeDispatchLog { /** The outer `run_code` execution. */ diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl new file mode 100644 index 0000000000..21b88b77ac --- /dev/null +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl @@ -0,0 +1,194 @@ +{"type":"session","version":0,"id":"main-session","createdAt":1785052797743,"cwd":"/tmp/dsh-tui-snapshot-code-mode-dispatch-spill-8cOdia"} +{"type":"turn/start","seq":0,"time":1785052797817,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785052797818,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool exactly once with the command `seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'`, then return ONLY the number of lines in its output. Reply with just that number and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785052797825,"data":{"title":"Using ONE run_code program: call","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785052797826,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785052797827,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785052798220,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1785052798221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1785052798391,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1785052798421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1785052798421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1785052798421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1785052798421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":12,"time":1785052798451,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":13,"time":1785052798452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":14,"time":1785052798452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":15,"time":1785052798452,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":16,"time":1785052798480,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":17,"time":1785052798480,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":18,"time":1785052798480,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} +{"type":"assistant/chunk","seq":19,"time":1785052798480,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":20,"time":1785052798509,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":21,"time":1785052798539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":22,"time":1785052798539,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":23,"time":1785052798569,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":24,"time":1785052798569,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":25,"time":1785052798599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":26,"time":1785052798599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":27,"time":1785052798599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":28,"time":1785052798599,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":29,"time":1785052798629,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}} +{"type":"assistant/chunk","seq":30,"time":1785052798629,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":31,"time":1785052798629,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" number"}}} +{"type":"assistant/chunk","seq":32,"time":1785052798629,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":33,"time":1785052798629,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} +{"type":"assistant/chunk","seq":34,"time":1785052798629,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":35,"time":1785052798659,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}} +{"type":"assistant/chunk","seq":36,"time":1785052798689,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":37,"time":1785052798690,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":38,"time":1785052798781,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":39,"time":1785052798781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":40,"time":1785052798781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":41,"time":1785052798781,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1785052798809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":43,"time":1785052798809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1785052798809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":45,"time":1785052798809,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1785052798839,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"Count"}}} +{"type":"assistant/chunk","seq":47,"time":1785052798868,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" lines"}}} +{"type":"assistant/chunk","seq":48,"time":1785052798869,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" in"}}} +{"type":"assistant/chunk","seq":49,"time":1785052798869,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" seq"}}} +{"type":"assistant/chunk","seq":50,"time":1785052798899,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"/"}}} +{"type":"assistant/chunk","seq":51,"time":1785052798929,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"awk"}}} +{"type":"assistant/chunk","seq":52,"time":1785052798930,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" output"}}} +{"type":"assistant/chunk","seq":53,"time":1785052798930,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1785052798960,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":55,"time":1785052798960,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":56,"time":1785052798960,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":57,"time":1785052798988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1785052798989,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":59,"time":1785052798989,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":60,"time":1785052798989,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":61,"time":1785052799019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":62,"time":1785052799019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":63,"time":1785052799019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":64,"time":1785052799019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":65,"time":1785052799019,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":66,"time":1785052799020,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":67,"time":1785052799048,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"({\\n"}}} +{"type":"assistant/chunk","seq":68,"time":1785052799049,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":69,"time":1785052799049,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":70,"time":1785052799049,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":71,"time":1785052799049,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":72,"time":1785052799049,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"seq"}}} +{"type":"assistant/chunk","seq":73,"time":1785052799107,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":74,"time":1785052799108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":75,"time":1785052799108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":76,"time":1785052799123,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"200"}}} +{"type":"assistant/chunk","seq":77,"time":1785052799123,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" |"}}} +{"type":"assistant/chunk","seq":78,"time":1785052799123,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" awk"}}} +{"type":"assistant/chunk","seq":79,"time":1785052799123,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" '{"}}} +{"type":"assistant/chunk","seq":80,"time":1785052799123,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"printf"}}} +{"type":"assistant/chunk","seq":81,"time":1785052799162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" \\\\\\\""}}} +{"type":"assistant/chunk","seq":82,"time":1785052799162,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"line"}}} +{"type":"assistant/chunk","seq":83,"time":1785052799163,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" %"}}} +{"type":"assistant/chunk","seq":84,"time":1785052799163,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"04"}}} +{"type":"assistant/chunk","seq":85,"time":1785052799191,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"d"}}} +{"type":"assistant/chunk","seq":86,"time":1785052799191,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":87,"time":1785052799191,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":88,"time":1785052799220,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" quick"}}} +{"type":"assistant/chunk","seq":89,"time":1785052799220,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" brown"}}} +{"type":"assistant/chunk","seq":90,"time":1785052799220,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" fox"}}} +{"type":"assistant/chunk","seq":91,"time":1785052799220,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" jumps"}}} +{"type":"assistant/chunk","seq":92,"time":1785052799220,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" over"}}} +{"type":"assistant/chunk","seq":93,"time":1785052799220,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":94,"time":1785052799250,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" lazy"}}} +{"type":"assistant/chunk","seq":95,"time":1785052799250,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" dog"}}} +{"type":"assistant/chunk","seq":96,"time":1785052799250,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\\\\\\\\"}}} +{"type":"assistant/chunk","seq":97,"time":1785052799250,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"n"}}} +{"type":"assistant/chunk","seq":98,"time":1785052799250,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\\\\\\\","}}} +{"type":"assistant/chunk","seq":99,"time":1785052799251,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" $"}}} +{"type":"assistant/chunk","seq":100,"time":1785052799280,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":101,"time":1785052799281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"}'"}}} +{"type":"assistant/chunk","seq":102,"time":1785052799281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\\\",\\n"}}} +{"type":"assistant/chunk","seq":103,"time":1785052799281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":104,"time":1785052799281,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":105,"time":1785052799310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":106,"time":1785052799310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":107,"time":1785052799310,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"Generate"}}} +{"type":"assistant/chunk","seq":108,"time":1785052799350,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":109,"time":1785052799350,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"200"}}} +{"type":"assistant/chunk","seq":110,"time":1785052799350,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" lines"}}} +{"type":"assistant/chunk","seq":111,"time":1785052799370,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" of"}}} +{"type":"assistant/chunk","seq":112,"time":1785052799371,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" text"}}} +{"type":"assistant/chunk","seq":113,"time":1785052799400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\\\"\\n"}}} +{"type":"assistant/chunk","seq":114,"time":1785052799400,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"});\\n\\n"}}} +{"type":"assistant/chunk","seq":115,"time":1785052799430,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"//"}}} +{"type":"assistant/chunk","seq":116,"time":1785052799430,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" Count"}}} +{"type":"assistant/chunk","seq":117,"time":1785052799460,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" lines"}}} +{"type":"assistant/chunk","seq":118,"time":1785052799460,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" in"}}} +{"type":"assistant/chunk","seq":119,"time":1785052799460,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":120,"time":1785052799491,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\\n"}}} +{"type":"assistant/chunk","seq":121,"time":1785052799491,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":122,"time":1785052799491,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" lines"}}} +{"type":"assistant/chunk","seq":123,"time":1785052799521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":124,"time":1785052799521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":125,"time":1785052799521,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":".k"}}} +{"type":"assistant/chunk","seq":126,"time":1785052799551,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"ind"}}} +{"type":"assistant/chunk","seq":127,"time":1785052799551,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" ==="}}} +{"type":"assistant/chunk","seq":128,"time":1785052799551,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":129,"time":1785052799581,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"fore"}}} +{"type":"assistant/chunk","seq":130,"time":1785052799581,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"ground"}}} +{"type":"assistant/chunk","seq":131,"time":1785052799582,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":132,"time":1785052799582,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" ?"}}} +{"type":"assistant/chunk","seq":133,"time":1785052799611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" result"}}} +{"type":"assistant/chunk","seq":134,"time":1785052799611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":".stdout"}}} +{"type":"assistant/chunk","seq":135,"time":1785052799611,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":".text"}}} +{"type":"assistant/chunk","seq":136,"time":1785052799641,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":137,"time":1785052799672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"()."}}} +{"type":"assistant/chunk","seq":138,"time":1785052799672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"split"}}} +{"type":"assistant/chunk","seq":139,"time":1785052799672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"(\\\"\\\\"}}} +{"type":"assistant/chunk","seq":140,"time":1785052799672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"n"}}} +{"type":"assistant/chunk","seq":141,"time":1785052799672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\\\")."}}} +{"type":"assistant/chunk","seq":142,"time":1785052799672,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"length"}}} +{"type":"assistant/chunk","seq":143,"time":1785052799702,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" :"}}} +{"type":"assistant/chunk","seq":144,"time":1785052799702,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" "}}} +{"type":"assistant/chunk","seq":145,"time":1785052799702,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"0"}}} +{"type":"assistant/chunk","seq":146,"time":1785052799702,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":";\\n"}}} +{"type":"assistant/chunk","seq":147,"time":1785052799703,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":148,"time":1785052799703,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":" lines"}}} +{"type":"assistant/chunk","seq":149,"time":1785052799731,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":";"}}} +{"type":"assistant/chunk","seq":150,"time":1785052799731,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":151,"time":1785052799731,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":152,"time":1785052799793,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."}}}} +{"type":"assistant/chunk","seq":153,"time":1785052799793,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}}}} +{"type":"assistant/chunk","seq":154,"time":1785052799794,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":155,"time":1785052799794,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":156,"time":1785052799798,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to write a single run_code program that calls bash exactly once with a specific command, then returns only the number of lines in its output."},{"type":"tool-call","id":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":90,"outputTokens":186,"cacheReadTokens":3968,"reasoningTokens":32}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155],"surfaceOp":"append"} +{"type":"tool/call","seq":157,"time":1785052799799,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","name":"run_code","arguments":"{\"description\": \"Count lines in seq/awk output\", \"code\": \"const result = await tools.bash({\\n command: \\\"seq 1 200 | awk '{printf \\\\\\\"line %04d: the quick brown fox jumps over the lazy dog\\\\\\\\n\\\\\\\", $1}'\\\",\\n description: \\\"Generate 200 lines of text\\\"\\n});\\n\\n// Count lines in stdout\\nconst lines = result.kind === \\\"foreground\\\" ? result.stdout.text.trim().split(\\\"\\\\n\\\").length : 0;\\nreturn lines;\"}"}} +{"type":"tool/code-dispatch-start","seq":158,"time":1785052799893,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"}}} +{"type":"tool/code-dispatch","seq":159,"time":1785052799923,"data":{"parentCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","subCallId":"call_00_R6g9Uzx4h0jeUv9g3fno7490:code:1","name":"bash","arguments":{"command":"seq 1 200 | awk '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}'","description":"Generate 200 lines of text"},"isError":false,"content":[{"type":"text","text":"line 0001: the quick brown fox jumps over the lazy dog\nline 0002: the quick brown fox jumps over the lazy dog\nline 0003: the quick brown fox jumps over the lazy dog\nline 0004: the quick s over the lazy dog\nline 0198: the quick brown fox jumps over the lazy dog\nline 0199: the quick brown fox jumps over the lazy dog\nline 0200: the quick brown fox jumps over the lazy dog\n\n\n(Omitted 10629 bytes. Full formatted result stored at: /tmp/dsh-tui-snapshot-code-mode-dispatch-spill-8cOdia/.spill/session-2d2b9e84a250/825a63550249-bash.txt. Use read with offset/limit, or grep this path to search within it.)"}]}} +{"type":"tool/result","seq":160,"time":1785052799925,"data":{"turn":1,"step":1,"callId":"call_00_R6g9Uzx4h0jeUv9g3fno7490","content":[{"type":"text","text":"200"}],"isError":false},"sourceEventSeqs":[157],"surfaceOp":"append"} +{"type":"step/end","seq":161,"time":1785052799926,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":162,"time":1785052799928,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":163,"time":1785052800414,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":164,"time":1785052800415,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":165,"time":1785052800572,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":166,"time":1785052800604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":167,"time":1785052800604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}} +{"type":"assistant/chunk","seq":168,"time":1785052800604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"200"}}} +{"type":"assistant/chunk","seq":169,"time":1785052800604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" lines"}}} +{"type":"assistant/chunk","seq":170,"time":1785052800605,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":171,"time":1785052800635,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":172,"time":1785052800636,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":173,"time":1785052800666,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":174,"time":1785052800666,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":175,"time":1785052800666,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":176,"time":1785052800666,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":177,"time":1785052800699,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":178,"time":1785052800700,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":179,"time":1785052800700,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":180,"time":1785052800700,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" number"}}} +{"type":"assistant/chunk","seq":181,"time":1785052800700,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":182,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":183,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":184,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":185,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"200"}}} +{"type":"assistant/chunk","seq":186,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The result is 200 lines. The user wants me to reply with just that number and stop."}}}} +{"type":"assistant/chunk","seq":187,"time":1785052800731,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"200"}}}} +{"type":"assistant/chunk","seq":188,"time":1785052800732,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":33,"outputTokens":22,"cacheReadTokens":4224,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":189,"time":1785052800732,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":190,"time":1785052800733,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The result is 200 lines. The user wants me to reply with just that number and stop."},{"type":"text","text":"200"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":33,"outputTokens":22,"cacheReadTokens":4224,"reasoningTokens":20}},"sourceEventSeqs":[163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189],"surfaceOp":"append"} +{"type":"step/end","seq":191,"time":1785052800733,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":192,"time":1785052800733,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt new file mode 100644 index 0000000000..aad4b2cd50 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt @@ -0,0 +1,65 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "Using ONE run_code program: call — DSH TUI snapshot" +cursor hidden column=1 viewportRow=26 bufferRow=26 +buffer +0| " DEEPSEEK HARNESS" + style 1-8 fg=bright-blue bold + style 10-16 bold +1| " Using ONE run_code program: call" + style 1-32 fg=bright-black +2| " deepseek-v4-flash • main-session" + style 1-34 dim +3| +4| "▌ " + style 0-0 fg=bright-blue +5| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +6| "▌ Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " + style 0-0 fg=bright-blue + style 79-99 fg=cyan +7| "▌ '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " + style 0-0 fg=bright-blue + style 2-74 fg=cyan +8| "▌ number of lines in its output. Reply with just that number and stop. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Reasoning " + style 1-9 fg=bright-black italic +12| " The user wants me to write a single run_code program that calls bash exactly once with a specific " + style 1-99 fg=bright-black italic +13| " command, then returns only the number of lines in its output. " + style 1-61 fg=bright-black italic +14| +15| "▌ " + style 0-0 fg=green +16| "▌ ✓ Count lines in seq/awk output " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-32 bold +17| "▌ 200 " + style 0-0 fg=green +18| "▌ " + style 0-0 fg=green +19| +20| " Reasoning " + style 1-9 fg=bright-black italic +21| " The result is 200 lines. The user wants me to reply with just that number and stop. " + style 1-83 fg=bright-black italic +22| +23| " Assistant " + style 1-9 fg=bright-magenta bold +24| " 200 " +25| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +26| " " + style 1-1 inverse +27| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +28| "deepseek-v4-flash /workspace/project ↑123 ↓208 cache 99% 3% c" + style 0-93 dim + style 96-99 dim +29-35| diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 26ba64f23b..1341a22291 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -27,6 +27,8 @@ import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' import { createTuiChat, FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-tui' +import LocalSpillStore from '@deepseek-ai/dsh-spill-local' +import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts' @@ -56,6 +58,13 @@ interface Scenario { * mounts it; the rest cover the default, todo-free composition. */ enableTodo?: boolean + /** + * Mount the spill stack (local backend + policy) with this inline cap, as the + * shipped configs do. The dispatch-spill scenario proves the durable + * `tool/code-dispatch` copy of an oversized sub-result is bounded to a + * preview + locator while the program value stays whole. + */ + spillMaxInlineBytes?: number } const SCENARIOS: Scenario[] = [ @@ -96,6 +105,14 @@ const SCENARIOS: Scenario[] = [ expectedEventCounts: { 'tool/code-dispatch': 2 }, recorded: true, }, + { + name: 'code-mode-dispatch-spill', + composition: 'code', + expectedTools: ['run_code'], + expectedEventCounts: { 'tool/code-dispatch-start': 1, 'tool/code-dispatch': 1 }, + recorded: true, + spillMaxInlineBytes: 600, + }, { name: 'dynamic-workflow', composition: 'native', @@ -225,6 +242,10 @@ async function mountScenarioContext( if (scenario.composition === 'code' || scenario.composition === 'advanced') { await ctx.plugin(WorkerCodeRuntime, {}) } + if (scenario.spillMaxInlineBytes !== undefined) { + await ctx.plugin(LocalSpillStore, { root: join(cwd, '.spill') }) + await ctx.plugin(SpillPolicy, { maxInlineBytes: scenario.spillMaxInlineBytes }) + } if (scenario.composition === 'advanced') await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 }) if (MODE === 'record' && scenario.recorded) { await ctx.plugin(LlmDeepSeek) @@ -344,6 +365,17 @@ async function runScenario(scenario: Scenario): Promise { expect(events.filter(event => event.type === 'user/message' && event.data.source.kind === 'plugin').map(event => (event.data as { content: unknown }).content)) .toContainEqual([{ type: 'text', text: 'The user switched this session back to the default mode.' }]) } + if (scenario.spillMaxInlineBytes !== undefined) { + // The REAL pipeline ran (tools execute on replay too): the durable + // dispatch copy is bounded to a preview + locator under the run cwd, + // while the outer result still carries the program's whole value. + const dispatch = events.find(event => (event.type as string) === 'tool/code-dispatch') + const content = (dispatch?.data as { content: { type: string; text?: string }[] }).content + const text = content.filter(block => block.type === 'text').map(block => block.text ?? '').join('') + expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(scenario.spillMaxInlineBytes) + expect(text).toContain('Full formatted result stored at:') + expect(text).toContain('.spill') + } expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true) expect(events.filter(event => event.type === 'turn/end').every(event => event.data.reason.kind !== 'error')).toBe(true) if (scenario.name === 'dynamic-workflow' || scenario.name === 'cordis-dynamic-toolchain') { diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 7dead97517..f45bea489c 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -359,12 +359,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => // entries, awaits the live pool, and drains the ordered commit lane — // including a commit already in progress when the program returned. await drive() - // Every settle's shaped append lands inside the open run_code turn. - while (logWork.size > 0) { - const pending = [...logWork] - await Promise.allSettled(pending) - for (const done of pending) logWork.delete(done) - } + // Every settle's shaped append lands inside the open run_code turn + // (tasks self-remove on settlement). + while (logWork.size > 0) await Promise.allSettled([...logWork]) } // Read through a call, not a bare property: the abort state genuinely @@ -406,7 +403,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => : { isError: false, value: result.value }) const agent = exec.agent if (agent === undefined) return - logWork.add((async () => { + const task: Promise = (async () => { // The durable copy may be reshaped (e.g. spilled to a preview + // locator) by the log-shaping waterfall; the program's value // and the model contract are untouched. @@ -428,7 +425,8 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => isError: result.isError, content: logged, }) - })()) + })().finally(() => { logWork.delete(task) }) + logWork.add(task) } pendingQueue.push({ flight: Promise.resolve(), @@ -470,6 +468,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => exec.deferContext(context) } settle(result) + // Backpressure on the shaped-append side channel: pending log + // tasks (each retaining a full result while a slow backend + // stores it) are bounded by the pool cap — beyond it the + // ordered lane waits, so later sub-calls cannot start and + // pending I/O/memory cannot grow without bound. + while (logWork.size > maxParallel) await Promise.race(logWork) }, }) wakeup() diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 8cb2de6d5b..5536cc4753 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -289,8 +289,10 @@ export type ToolExecutionMode = * One settled `run_code` sub-dispatch about to be logged, as seen by the * `tools/code-dispatch-log` waterfall: the parent execution (session owner, * outer call identity), the sub-call identity, and the outcome whose durable - * copy a listener may reshape. The complete `content` is what the program - * already received; only the `tool/code-dispatch` event's copy changes. + * copy a listener may reshape. `content` is the RENDERED result projection + * (what a native `tool/result` would carry) — the program itself received + * the structured `value` (or just the error message on failure); only the + * `tool/code-dispatch` event's copy changes. */ export interface CodeDispatchLog { /** The outer `run_code` execution. */ @@ -991,7 +993,7 @@ export class ToolRegistry extends Service { () => Promise.resolve(dispatch.content), ) } catch (error: unknown) { - this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${String(error)}; logging the unshaped content`) + this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the unshaped content`) return dispatch.content } } diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index f132c0f98a..32b9483dca 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -29,9 +29,12 @@ const testToolSignal = new AbortController().signal class StubStore extends SpillStore { saves: SaveTextSpill[] = [] fail = false + /** Per-save hang hook: each call awaits the returned promise before completing. */ + gate: (() => Promise) | undefined async saveText(input: SaveTextSpill): Promise { if (this.fail) throw new Error('disk full') + await this.gate?.() this.saves.push(input) return { locator: SpillLocator(`/spill/${input.suggestedName}`), @@ -367,6 +370,63 @@ describe('the durable dispatch-log arm', () => { expect(smallAfterHuge).toBe(true) }) + it('a sustained slow backend backpressures the run instead of accumulating unbounded log tasks', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + // Cap 1: once the hung shaped-append backlog exceeds the cap, the ordered + // lane holds inside the second commit, so the THIRD dispatch cannot start + // until a pending save drains — the bound is observable as its missing + // start event. + await ctx.plugin(ToolRegistry, { mode: 'code', maxParallelSubCalls: 1 }) + await ctx.plugin(StubStore) + await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 }) + await ctx.plugin(WorkerCodeRuntime, {}) + const store = ctx.spillStore as StubStore + const releases: (() => void)[] = [] + store.gate = () => new Promise((resolve) => { releases.push(resolve) }) + const events: { type: string; data: unknown }[] = [] + const agent = { + session: { + header: { id: SessionId('dispatch-spill-bound'), cwd: '/workspace' }, + append: (type: string, data: unknown) => { events.push({ type, data }) }, + }, + } + ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000))) + const started = (n: number): boolean => events.some(event => event.type === 'tool/code-dispatch-start' + && (event.data as { subCallId: string }).subCallId.endsWith(`:code:${n}`)) + const runPromise = ctx.tools.execute({ + signal: testToolSignal, + callId: CallId('parent-bound'), + name: 'run_code', + arguments: { + code: 'await tools.huge_read({}); await tools.huge_read({}); await tools.huge_read({}); return "done"', + description: 'Three oversized reads against a hung backend', + }, + agent: agent as never, + }) + // Two hung saves = backlog above the cap: the lane must hold before + // starting dispatch 3. + await vi.waitFor(() => { + if (releases.length < 2) throw new Error('second hung save not reached yet') + }) + expect(started(2)).toBe(true) + expect(started(3)).toBe(false) + releases.shift()!() + // Draining one pending save releases the lane; dispatch 3 starts. + await vi.waitFor(() => { + if (!started(3)) throw new Error('third dispatch not started yet') + }) + while (releases.length > 0) releases.shift()!() + const result = await runPromise + expect(result.isError).toBe(false) + await vi.waitFor(() => { + if (releases.length > 0) { while (releases.length > 0) releases.shift()!() } + if (events.filter(event => event.type === 'tool/code-dispatch').length !== 3) { + throw new Error('settle events still pending') + } + }) + }) + it('a saveText failure keeps the complete content in the durable log (best-effort)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) From 79e72eb736741f8776148913208e27993c067f14 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:33:51 +0800 Subject: [PATCH 16/23] fix(ui-primitives): prototype-safe alias lookup; pre-warm shiki off the render path Responding to ds-review-bot round 2 on #662: - LANG_ALIASES is a Map: an assistant-authored fence label like constructor or __proto__ now misses (plain render) instead of resolving an inherited object property and crashing shiki mid-conversation. Test sweeps the inherited-key labels. - The singleton is pre-warmed in a deferred task at plugin boot (the ~120-175ms engine+grammar construction long task moves off the first finalized fence's render); the lazy path remains the correctness fallback, and unref keeps non-browser imports from pinning the loop. Agent Note updated (both languages). --- ...26-web-syntax-highlighting-shiki.i18n.yaml | 4 +- ...026-07-26-web-syntax-highlighting-shiki.md | 2 +- ...-07-26-web-syntax-highlighting-shiki.zh.md | 2 +- .../ui-primitives/src/markdown/highlight.ts | 48 ++++++++++++------- .../ui-primitives/tests/markdown.spec.tsx | 9 ++++ 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml index d0e217941b..9fd37bcedb 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.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-web-syntax-highlighting-shiki.md: 79ad2153b8883fda92205dada300fd194834129b -2026-07-26-web-syntax-highlighting-shiki.zh.md: 4cb3f0ceadebc4837108463c149262bf8e36f93d +2026-07-26-web-syntax-highlighting-shiki.md: b329e35f1d0ce7b3de454758403a09f67056b5af +2026-07-26-web-syntax-highlighting-shiki.zh.md: 8e9d1f0d0c38ce64bcb5da1262538da762f70b12 diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md index 79ad2153b8..b329e35f1d 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md @@ -15,7 +15,7 @@ The client rendered every code surface — markdown fences in assistant prose, t **Shiki in its synchronous fine-grained form, as one `ui-primitives` singleton, themed exclusively through CSS custom properties.** - **Dependency**: `shiki/core` + `@shikijs/langs`, composed via `createHighlighterCoreSync` with `createJavaScriptRegexEngine({ forgiving: true })` — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: `typescript` (embeds JS), `shellscript`, `json` — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here. -- **Singleton**: `ui-primitives/src/markdown/highlight.ts` lazily creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path. +- **Singleton**: `ui-primitives/src/markdown/highlight.ts` creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). Engine + grammar construction is a ~120-175ms long task, so the module pre-warms the singleton in a deferred task at plugin boot (the lazy path stays as the correctness fallback), keeping the cost off the render path where a stream's finalize swap would jank. The alias table is a `Map`, not an object: fence info strings are assistant-authored, so a label like `constructor` must miss instead of resolving an inherited property and crashing shiki. The shared `CodeBlock` component owns both arms; its shiki arm injects the generated span tree via `dangerouslySetInnerHTML` — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path. - **Theming**: shiki's `createCssVariablesTheme` routes every token color through `--shiki-*` custom properties; the VALUES live in a new `ui-theme/styles/shiki.css` token sheet (light on `:root`, dark on `body[data-ds-dark-theme]` — the same cascade as every other sheet), imported by the shell's `base.css` chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree. - **Surfaces**: markdown fences (`MarkdownText`'s `pre` component routes single-string fences through `CodeBlock`), the `run_code` expanded program body (ToolRow's code variant, `lang="typescript"`), and the details panel's Input args (`lang="json"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps. diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md index 4cb3f0cead..8e9d1f0d0c 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md @@ -15,7 +15,7 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围 **采用同步细粒度形态的 shiki,作为 `ui-primitives` 里的一个单例,主题化完全经由 CSS 自定义属性完成。** - **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。 -- **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 惰性创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。 +- **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。 - **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。 - **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。 diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 34e0359f60..1fa50f6d2f 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -18,21 +18,26 @@ import langBash from '@shikijs/langs/shellscript' import langJson from '@shikijs/langs/json' import type { HighlighterCore } from 'shiki/core' -/** Language ids (and aliases) the singleton registers; everything else renders plain. */ -const LANG_ALIASES: Record = { - typescript: 'typescript', - ts: 'typescript', - tsx: 'typescript', - javascript: 'typescript', - js: 'typescript', - shellscript: 'shellscript', - bash: 'shellscript', - sh: 'shellscript', - shell: 'shellscript', - zsh: 'shellscript', - json: 'json', - jsonc: 'json', -} +/** + * Language ids (and aliases) the singleton registers; everything else renders + * plain. A Map, not an object: fence info strings are assistant-authored, so + * a label like `constructor` or `__proto__` must miss instead of resolving an + * inherited property and crashing the renderer inside shiki. + */ +const LANG_ALIASES = new Map([ + ['typescript', 'typescript'], + ['ts', 'typescript'], + ['tsx', 'typescript'], + ['javascript', 'typescript'], + ['js', 'typescript'], + ['shellscript', 'shellscript'], + ['bash', 'shellscript'], + ['sh', 'shellscript'], + ['shell', 'shellscript'], + ['zsh', 'shellscript'], + ['json', 'json'], + ['jsonc', 'json'], +]) /** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */ const cssVariablesTheme = createCssVariablesTheme({ @@ -43,7 +48,7 @@ const cssVariablesTheme = createCssVariablesTheme({ let singleton: HighlighterCore | undefined -/** The lazily-created synchronous highlighter (one instance per document). */ +/** The synchronous highlighter (one instance per document); pre-warmed below, lazy as the fallback. */ function highlighter(): HighlighterCore { singleton ??= createHighlighterCoreSync({ themes: [cssVariablesTheme], @@ -53,6 +58,15 @@ function highlighter(): HighlighterCore { return singleton } +// Engine + grammar construction costs a long task (~120-175ms); building it +// during the first finalized fence's render would jank exactly when a stream +// completes. Warm the singleton in a deferred task at module load (= plugin +// boot) instead; the lazy path above stays as the correctness fallback for a +// fence that renders before the timer fires. `unref` (Node-only) keeps a +// non-browser import from pinning the event loop. +const warmupTimer = setTimeout(() => { highlighter() }, 0) +;(warmupTimer as { unref?: () => void }).unref?.() + /** * Highlight `code` into shiki's HTML (a single `
` tree)
  * when `lang` maps to a registered grammar; `undefined` means the caller
@@ -62,7 +76,7 @@ function highlighter(): HighlighterCore {
  * @returns the highlighted HTML, or `undefined` for unknown languages.
  */
 export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
-  const resolved = lang === undefined ? undefined : LANG_ALIASES[lang.toLowerCase()]
+  const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
   if (resolved === undefined) return undefined
   return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
 }
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx
index 00de9683ff..05c7ce0139 100644
--- a/packages/client/ui-primitives/tests/markdown.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.spec.tsx
@@ -64,6 +64,15 @@ describe('MarkdownText', () => {
     expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
   })
 
+  it('a fence labeled with an inherited object key renders plain, never crashing shiki', () => {
+    for (const label of ['constructor', '__proto__', 'toString', 'hasOwnProperty']) {
+      const { container, unmount } = render()
+      expect(container.querySelector('pre.shiki')).toBeNull()
+      expect(container.querySelector('pre code')?.textContent).toContain('code body')
+      unmount()
+    }
+  })
+
   it('an empty fence keeps the stock pre; a language-less fence renders the plain CodeBlock arm', () => {
     const empty = render()
     expect(empty.container.querySelector('pre')?.outerHTML).toBe('
') From a9b52d27a81461a76771addb5c2cc40d4c7a2edf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:30:43 +0800 Subject: [PATCH 17/23] test(snapshots): refresh cordis-inspect-jsdoc for the CodeDispatchLog JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scenario inspects the tools service API; the round-2 content-contract JSDoc change shifted its rendered output. Keyless DSH_SNAPSHOT=refresh — the resulting fixture is byte-identical to the one the shiki branch already carries (the downstream trees were green for this reason). --- .../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 a4644413e51b87b379a3380b9e909f30a55b4233 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:37:42 +0800 Subject: [PATCH 18/23] fix: restore master's branded-id casts in ui-workspace apply spec A stale-lib eslint --fix pass during the merge stripped the 'as never' casts the branded WorkspaceId/SessionId parameters require; typecheck rejects the push. Take master's version verbatim. --- packages/client/ui-workspace/tests/apply.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 196d05d46b..6e1c7a3a3f 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -55,13 +55,13 @@ describe('ui-workspace apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() - browser.startSession('ws', 'prompt') + browser.startSession('ws' as never, 'prompt') expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt') - browser.open('session') + browser.open('session' as never) expect(b.open).toHaveBeenCalledWith('session') - await browser.renameWorkspace('ws', 'renamed') + await browser.renameWorkspace('ws' as never, 'renamed') expect(b.rename).toHaveBeenCalledWith('ws', 'renamed') - await browser.insertSessionBefore('ws', 's1', 's2') + await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never) expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2') await browser.createWorkspace({ name: 'project' }) expect(b.create).toHaveBeenCalledWith({ name: 'project' }) From 443e2bc509a8dfd03fc07c8b46f82f152282bf55 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:52:15 +0800 Subject: [PATCH 19/23] refactor(tools): shapeDispatchLog off the public registry surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responding to review on #661: a public method on the generic ToolRegistry service whose only caller is the run_code bridge was ad-hoc surface widening. The bridge now receives it as a registry-private capability closure in RunCodeBridgeOptions (the requireRuntime idiom, alongside the cap), the method is private, and it leaves the generated service catalog/API surfaces. The pattern is now named as a code smell where reviewers look: the packages/AGENTS.md capability-interface rule gains the inverse-smell clause (ceiling 660→675 — the list is at capacity and the clause needs one sentence), and dsh-code-review's capability-fit check tells reviewers to flag single-consumer public service methods and require the closure form. --- .agents/skills/dsh-code-review/SKILL.md | 2 +- docs/cordis-catalog/services.md | 12 +-------- packages/AGENTS.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 ------ packages/core/tools/src/code-mode.ts | 26 ++++++++++++++----- packages/core/tools/src/index.ts | 15 +++++++---- scripts/doc-budgets.manifest.json | 2 +- 7 files changed, 33 insertions(+), 34 deletions(-) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 2c9fd86df5..47890519f2 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -30,7 +30,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - **Intent and seam contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal. - **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns.md](../../../docs/defensive-patterns.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal. -- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package contract](../../../packages/AGENTS.md). +- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package contract](../../../packages/AGENTS.md). Flag the inverse too: a new public method on a generic service (registry, session, agent) whose only caller is one internal consumer is an ad-hoc surface widening — require a private capability closure handed to that consumer at construction instead. - **Scope, ownership, and necessity:** map each abstraction, state machine, option, defensive copy, and compatibility path to its current contract, production consumer, and owning plugin or service. Challenge unrelated features and speculative generality, then test the PR's coherence against [the root contract](../../../AGENTS.md#conventions). - **Configuration and public choices:** ask what current-consumer evidence or prior art supports each default, public operation set, format, or imported external concept. Require an explicit choice or deferral when that evidence is absent. - **Model perspective:** inspect the exact prompts, tool schemas, results, and diagnostics the model receives across affected modes. Flag concepts outside the model's task, then verify stable text verbatim and dynamic behavior through snapshots or end-to-end coverage. diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ccb8e8f990..7ee7f89ac8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1830,16 +1830,6 @@ schemas(scope?: ScopeKey): ToolSchema[] */ executionMode(exec: ToolExecutionInput): ToolExecutionMode -/** - * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch - * and return the content the bridge should log on `tool/code-dispatch`. - * Contained: a throwing listener falls back to the unshaped content — log - * shaping must never fail the dispatch or lose the settle event. - * @param dispatch - the sub-dispatch identity and its default logged content. - * @returns the (possibly reshaped) content for the durable event. - */ -async shapeDispatchLog(dispatch: CodeDispatchLog): Promise - /** * Execute through pre-policy, guards, around-dispatch, post-policy, * definition-owned content finalization, and final notification. Tool and @@ -1857,7 +1847,7 @@ async shapeDispatchLog(dispatch: CodeDispatchLog): Promise async execute(exec: ToolExecutionInput): Promise ``` -Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) +Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) Source: [`packages/core/tools/src/index.ts:688`](../../packages/core/tools/src/index.ts) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index bb7fdfa839..0e8c62841e 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -7,7 +7,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). - **Initiator-owned private chains derive, then capture.** Under `ctx.agents.withInitiator()`, recover the Agent at each orchestration entry, derive `agent.session`, and let operation-local helpers close over it. Keep `Agent` and `Session` explicit at lifecycle, session-log, service, authority, worker/process, persistence, and wire interfaces; do not widen a leaf helper from `Session` to `Context` merely to hide a parameter ([rationale](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)). - **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. -- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). +- **Shape capability interfaces around all current consumers.** Keep tool-schema, Loader, UI, transport, and backend-specific behavior in the consumer or adapter; do not let one consumer dictate the interface ([capability-seam rationale](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). Inverse smell: a public service method with one internal caller — pass a private capability closure instead (`RunCodeBridgeOptions`). - **Require a current owner and need.** Tie each abstraction, state machine, option, defensive copy, and compatibility path to a current contract or production consumer, and keep behavior in its owning plugin or service. - **Require evidence for public choices.** Configurability does not justify an unsupported default, public operation set, format, or imported external concept. Use current-consumer evidence or relevant prior art; otherwise require an explicit value or defer the choice. - **Write model-facing contracts from the model's perspective.** Prompts, tool schemas, results, and diagnostics contain only task-relevant concepts, not UI, transport, or implementation vocabulary. Pin stable model-visible text verbatim and dynamic behavior through snapshots or end-to-end coverage. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1d1c6c3b00..41c1fd9801 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -864,10 +864,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'executionMode(exec: ToolExecutionInput): ToolExecutionMode', jsDoc: '/**\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 */', }, - { - signature: 'async shapeDispatchLog(dispatch: CodeDispatchLog): Promise', - jsDoc: '/**\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 */', - }, { signature: 'async execute(exec: ToolExecutionInput): Promise', jsDoc: '/**\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 */', @@ -1439,10 +1435,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeBindingNamespace', declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n errorClass?: CodeBindingErrorClass;\n}', }, - { - name: 'CodeDispatchLog', - declaration: '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}', - }, { name: 'CodeJsonValue', declaration: 'export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | {\n [key: string]: CodeJsonValue;\n};', diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index f45bea489c..80c382915f 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -13,7 +13,7 @@ import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-session' import { defineTool } from './schema.ts' import { TOOL_REGISTRY_SCHEDULER } from './index.ts' -import type { ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts' +import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRegistry, ToolRunContext } from './index.ts' declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { @@ -186,6 +186,20 @@ function renderValue(value: JsonValue): string { /** Canonical value returned by the outer Code Mode transport. */ type RunCodeOutput = { logs: string[]; result?: JsonValue } +/** + * Registry-private capabilities the bridge receives at construction — the + * `requireRuntime` idiom: operations only the owning registry can mint stay + * off its public service surface and flow here as closures instead. + */ +export interface RunCodeBridgeOptions { + /** Resolves `ctx.codeRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */ + requireRuntime: () => CodeRuntime + /** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */ + maxParallel: number + /** Runs the contained `tools/code-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */ + shapeDispatchLog: (dispatch: CodeDispatchLog) => Promise +} + /** * Build the `run_code` {@link ToolDefinition}: required `code` and * `description` parameters, executed through the dispatch bridge described @@ -194,13 +208,11 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue } * outside the filterable global/scoped capability layers. * @param registry - the owning registry (sub-calls go through its `execute`, * bindings cover its registered tools). - * @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud - * misconfiguration error (shared with the registry's assembly-time checks). - * @param maxParallel - the run's overlap cap for parallel-classified - * sub-calls (the registry passes its validated `maxParallelSubCalls`). + * @param options - the registry-private capabilities described above. * @returns the registry-ready definition. */ -export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime, maxParallel: number): ToolDefinition { +export function createRunCodeTool(registry: ToolRegistry, options: RunCodeBridgeOptions): ToolDefinition { + const { requireRuntime, maxParallel, shapeDispatchLog } = options return defineTool({ name: RUN_CODE_NAME, description: @@ -407,7 +419,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => // The durable copy may be reshaped (e.g. spilled to a preview + // locator) by the log-shaping waterfall; the program's value // and the model contract are untouched. - const logged = await registry.shapeDispatchLog({ + const logged = await shapeDispatchLog({ exec, agent, subCallId, name, isError: result.isError, // The registry deep-froze this projection at result // finalization; append snapshots the final copy again, so diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 5536cc4753..7b7b9ca353 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -727,7 +727,11 @@ export class ToolRegistry extends Service { // the filterable global/scoped capability layers. this.codeTransport = this.mode === 'native' ? undefined - : createRunCodeTool(this, () => this.requireCodeRuntime(), resolveMaxParallelSubCalls(config.maxParallelSubCalls)) + : createRunCodeTool(this, { + requireRuntime: () => this.requireCodeRuntime(), + maxParallel: resolveMaxParallelSubCalls(config.maxParallelSubCalls), + shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), + }) ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) if (this.mode !== 'native') { ctx.systemPrompt.section({ @@ -982,11 +986,12 @@ export class ToolRegistry extends Service { * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch * and return the content the bridge should log on `tool/code-dispatch`. * Contained: a throwing listener falls back to the unshaped content — log - * shaping must never fail the dispatch or lose the settle event. - * @param dispatch - the sub-dispatch identity and its default logged content. - * @returns the (possibly reshaped) content for the durable event. + * shaping must never fail the dispatch or lose the settle event. Private: + * the ONE consumer is the `run_code` bridge this registry constructs, which + * receives it as a capability parameter (the `requireRuntime` idiom) — the + * waterfall, not this invoker, is the public extension seam. */ - async shapeDispatchLog(dispatch: CodeDispatchLog): Promise { + private async shapeDispatchLog(dispatch: CodeDispatchLog): Promise { try { return await this.ctx.waterfall( scopeTarget(this, dispatch.agent), 'tools/code-dispatch-log', dispatch, diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 3d0ce17051..f1d40380e5 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -6,6 +6,6 @@ "docs/defensive-patterns.md": 550, "docs/testing.md": 1100, "examples/AGENTS.md": 310, - "packages/AGENTS.md": 660, + "packages/AGENTS.md": 675, "packages/README.md": 835 } From 0b797a776f396b32ec9ba020a7b4a3cb66198e63 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:53:47 +0800 Subject: [PATCH 20/23] docs: note the private capability-closure shape for the dispatch-log invoker --- .../feature/2026-07-26-code-dispatch-log-spill.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-26-code-dispatch-log-spill.md | 2 +- .../feature/2026-07-26-code-dispatch-log-spill.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml index b00bff1000..dd94eb6cd5 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.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-dispatch-log-spill.md: 65af7808c493867cb13042a4f169ffdf05eb4538 -2026-07-26-code-dispatch-log-spill.zh.md: e1293e62f9de9860300428c5c0d25c5404dc76f9 +2026-07-26-code-dispatch-log-spill.md: eee8fb73b3f1ddba0a2da3ad5a9d2d4417d5951c +2026-07-26-code-dispatch-log-spill.zh.md: 664a2aefcfef198d56809c289e10827a8084a06a diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md index 65af7808c4..eee8fb73b3 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md @@ -14,7 +14,7 @@ Since the full-content dispatch logging landed, a `run_code` program that reads **A log-shaping waterfall on the registry, and the spill policy as its first listener.** -- **Seam**: `tools/code-dispatch-log` — a scope-filtered waterfall the bridge runs (via `registry.shapeDispatchLog`, contained: a throwing listener falls back to the unshaped content, with total error formatting so a hostile thrown value cannot escape the containment) over each settled sub-dispatch before appending `tool/code-dispatch`. The payload (`CodeDispatchLog`) carries the outer execution, the hoisted `agent` routing key, the sub-call identity, and the default content — the RENDERED result projection a native `tool/result` would carry (the program itself received the structured `value`). Only the durable copy is shapeable; the model sees neither. Shaping runs OFF the program path as tracked side work, but bounded: past `maxParallelSubCalls` pending log tasks the ordered commit lane holds, so a slow spill backend backpressures the run instead of accumulating unbounded pending I/O; run settlement still drains every task inside the open turn. +- **Seam**: `tools/code-dispatch-log` — a scope-filtered waterfall the bridge runs (via the registry's PRIVATE `shapeDispatchLog` invoker, handed to the bridge as a capability closure in `RunCodeBridgeOptions` — the waterfall is the public seam, the invoker never widens the service surface; contained: a throwing listener falls back to the unshaped content, with total error formatting so a hostile thrown value cannot escape the containment) over each settled sub-dispatch before appending `tool/code-dispatch`. The payload (`CodeDispatchLog`) carries the outer execution, the hoisted `agent` routing key, the sub-call identity, and the default content — the RENDERED result projection a native `tool/result` would carry (the program itself received the structured `value`). Only the durable copy is shapeable; the model sees neither. Shaping runs OFF the program path as tracked side work, but bounded: past `maxParallelSubCalls` pending log tasks the ordered commit lane holds, so a slow spill backend backpressures the run instead of accumulating unbounded pending I/O; run settlement still drains every task inside the open turn. - **Policy**: `dsh-spill-policy` registers a second arm on the new seam sharing the exact replacement pipeline of its model-facing arm (same `maxInlineBytes` cap, same preview + locator + within-cap invariant, same best-effort fallbacks), with the artifact labeled `dispatch` under the sub-call id. UIs and replay read the full text through the spill artifact exactly as they do for spilled native results, so the native-parity rendering story survives bounding. - **One deliberate asymmetry**: the model-facing arm skips `read` (the `read → spill → read again` loop); the dispatch-log arm bounds `read` sub-calls too — a log copy is not model context, so the loop cannot happen, and `read` is precisely the tool that produces huge logs. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md index e1293e62f9..664a2aefcf 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md @@ -14,7 +14,7 @@ Status: implemented **在注册表上增设一个日志整形 waterfall(瀑布式事件),spill 策略作为其第一个监听器。** -- **Seam**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由 `registry.shapeDispatchLog`,且故障被兜住:监听器抛出异常时回退到未整形的内容,并用全防御的错误格式化确保恶意抛出值无法逃出兜底)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容——即原生 `tool/result` 所载的渲染后结果投影(程序本身收到的是结构化 `value`)。可整形的只有持久副本;模型两者都看不到。整形作为被跟踪的旁路工作在程序路径之外运行,但有界:待处理日志任务超过 `maxParallelSubCalls` 时有序提交车道会暂停,因此慢速 spill 后端会对整个 run 施加背压,而不是无限累积待完成 I/O;run 结算仍会在开放轮次内排空全部任务。 +- **Seam**:`tools/code-dispatch-log`,一个按作用域过滤的 waterfall,由桥接层在追加 `tool/code-dispatch` 之前对每个已结算的子分发运行(经由注册表的私有 `shapeDispatchLog` 调用器——作为能力闭包经 `RunCodeBridgeOptions` 交给桥接层;waterfall 才是公开 seam,调用器绝不加宽服务表面。故障被兜住:监听器抛出异常时回退到未整形的内容,并用全防御的错误格式化确保恶意抛出值无法逃出兜底)。载荷(`CodeDispatchLog`)携带外层执行、提升出来的 `agent` 路由键、子调用标识与默认内容——即原生 `tool/result` 所载的渲染后结果投影(程序本身收到的是结构化 `value`)。可整形的只有持久副本;模型两者都看不到。整形作为被跟踪的旁路工作在程序路径之外运行,但有界:待处理日志任务超过 `maxParallelSubCalls` 时有序提交车道会暂停,因此慢速 spill 后端会对整个 run 施加背压,而不是无限累积待完成 I/O;run 结算仍会在开放轮次内排空全部任务。 - **策略**:`dsh-spill-policy` 在新 seam 上注册第二个分支,与其面向模型的分支共用一模一样的替换流水线(同样的 `maxInlineBytes` 上限、同样的预览 + 定位符 + 不超上限不变式、同样的尽力而为回退),产物以 `dispatch` 为标签,记在子调用 id 名下。UI 与回放通过 spill 产物读取全文,方式与读取被 spill 的原生结果完全相同,因此与原生同等保真的渲染在施加边界之后依然成立。 - **一处有意的不对称**:面向模型的分支跳过 `read`(避免 `read → spill → read again` 循环);分发日志分支则连 `read` 子调用也施加边界:日志副本不是模型上下文,该循环因此不可能发生,而 `read` 恰恰是会产生巨大日志的那个工具。 From f8be35943cc03dcf0eb29ec5412cedad29fec62e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:02:35 +0800 Subject: [PATCH 21/23] =?UTF-8?q?test(snapshots):=20refresh=20cordis-inspe?= =?UTF-8?q?ct-jsdoc=20=E2=80=94=20shapeDispatchLog=20left=20the=20public?= =?UTF-8?q?=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../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 efb527afae..487f0517b1 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 * 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":"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":"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 8ceb638bb5e9c001d821bca7b62ed0ffdadaf932 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:21:44 +0800 Subject: [PATCH 22/23] =?UTF-8?q?docs(skills):=20record-browser-gif=20?= =?UTF-8?q?=E2=80=94=20assets-branch=20publishing=20+=20mandatory=20GUI-PR?= =?UTF-8?q?=20gifs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every PR that changes product-user-visible GUI behavior now includes a demonstration GIF with real provenance (that branch's built tree, real key, real model rounds). Recording stays side-effect-free; the skill gains a bounded final publication step: GIFs go on an append-only orphan assets branch (one per PR series) and embed via the blob URL with ?raw=true, never on the PR branch itself. Folds in the operational lessons from the Code Mode UI series: .playwright-mcp/ screenshot roots (now gitignored), per-PR staging and precise server teardown, one-call DOM polling for transient states, exact-text completion predicates, prompt engineering for UI states, and the export-before-invoke GIF_SKILL_DIR encoder pitfall. Agent Note: implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch (+ zh pair); the 2026-07-23 recording note now defers publication policy to it. --- ...07-23-browser-demo-gif-recording.i18n.yaml | 4 +- .../2026-07-23-browser-demo-gif-recording.md | 6 +- ...026-07-23-browser-demo-gif-recording.zh.md | 6 +- ...r-gif-evidence-and-assets-branch.i18n.yaml | 6 ++ ...6-gui-pr-gif-evidence-and-assets-branch.md | 39 ++++++++++ ...ui-pr-gif-evidence-and-assets-branch.zh.md | 39 ++++++++++ .agents/skills/record-browser-gif/SKILL.md | 75 +++++++++++++++---- .gitignore | 1 + 8 files changed, 154 insertions(+), 22 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md create mode 100644 .agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md diff --git a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.i18n.yaml index 1aee1563ad..a8cc857059 100644 --- a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.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-23-browser-demo-gif-recording.md: 096edf453d6b61c4d9046b284ef67a460edf4e88 -2026-07-23-browser-demo-gif-recording.zh.md: f5b8eac1c8dd57a59e9c2293ecc71511078a4896 +2026-07-23-browser-demo-gif-recording.md: 2213b8cd1be0a05638ce659840150e21d3a927bc +2026-07-23-browser-demo-gif-recording.zh.md: 391af22ea92cb7de61fa4254153209fbbcc3ed68 diff --git a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md index 096edf453d..2213b8cd1b 100644 --- a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md +++ b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.md @@ -10,9 +10,9 @@ Browser demonstrations have been assembled with one-off capture and encoding com ## Decision -The repository provides the [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) skill for local browser-demo artifacts. It uses the available browser-control workflow, establishes whether the requested flow is real, fixture-backed, or otherwise simulated, and captures a small storyboard only after semantically observable UI states. Frames and the output live outside the Git worktree by default. +The repository provides the [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) skill for local browser-demo artifacts. It uses the available browser-control workflow, establishes whether the requested flow is real, fixture-backed, or otherwise simulated, and captures a small storyboard only after semantically observable UI states. Frames live under the repository's gitignored `.playwright-mcp/` directory — the browser tool writes only under its allowed roots — and never dirty the worktree. -The bundled `encode_gif.py` helper orders frames lexically, assigns explicit hold durations, uses an `ffmpeg` palette pipeline, and validates source dimensions plus the encoded frame count, dimensions, duration, and byte limit through `ffprobe`. The workflow stops after returning the verified absolute GIF path; uploading the artifact and mutating a pull request, issue, or document remain separate workflows. +The bundled `encode_gif.py` helper orders frames lexically, assigns explicit hold durations, uses an `ffmpeg` palette pipeline, and validates source dimensions plus the encoded frame count, dimensions, duration, and byte limit through `ffprobe`. Recording stops after returning the verified absolute GIF path; when the task includes attaching the GIF to a pull request, the [GUI-PR GIF evidence decision](2026-07-26-gui-pr-gif-evidence-and-assets-branch.md) owns the mandatory-evidence policy and the assets-branch publication step that follows. ## Alternatives considered @@ -20,7 +20,7 @@ The bundled `encode_gif.py` helper orders frames lexically, assigns explicit hol **Keep an inline `ffmpeg` recipe in the skill.** Reconstructing quoting, timing manifests, palette filters, overwrite behavior, and post-encode checks in every run is error-prone. A bundled helper keeps those mechanics executable while the skill owns capture judgment. -**Include GitHub attachment and description editing.** Upload and remote mutation require separate authentication, confirmation, and recovery rules. Excluding them keeps invocation of a recording skill local and reversible. +**Include GitHub attachment and description editing.** Upload and remote mutation require separate authentication, confirmation, and recovery rules. Keeping recording itself local and reversible preserves that boundary; the [GUI-PR GIF evidence decision](2026-07-26-gui-pr-gif-evidence-and-assets-branch.md) owns the bounded publication step for tasks that do attach the GIF to a pull request. **Use a fixture whenever it is easier to stage.** Fixtures are valid when the requested demonstration is explicitly fixture-backed, but they do not substantiate a real-server or real-API claim. The skill preserves the requested provenance and reports a missing prerequisite instead of silently changing it. diff --git a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md index f5b8eac1c8..391af22ea9 100644 --- a/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-browser-demo-gif-recording.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -仓库提供 [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) skill(技能),用于生成本地浏览器演示产物。该 skill 使用当前可用的浏览器控制工作流,先确认请求的流程是真实流程、由 fixture 支撑,还是采用其他模拟方式,再仅在 UI 达到语义上可观察的状态后截取一组精简的分镜帧。帧文件与输出产物默认存放在 Git worktree 之外。 +仓库提供 [`record-browser-gif`](../../../skills/record-browser-gif/SKILL.md) skill(技能),用于生成本地浏览器演示产物。该 skill 使用当前可用的浏览器控制工作流,先确认请求的流程是真实流程、由 fixture 支撑,还是采用其他模拟方式,再仅在 UI 达到语义上可观察的状态后截取一组精简的分镜帧。帧文件存放在仓库 `.gitignore` 忽略的 `.playwright-mcp/` 目录下(浏览器工具只能写入其允许的根目录),不会弄脏 worktree。 -随附的 `encode_gif.py` 辅助脚本按词法顺序排列各帧,为每帧设置明确的停留时长,通过 `ffmpeg` 调色板流水线编码,并借助 `ffprobe` 校验源图像尺寸以及编码结果的帧数、尺寸、时长和字节上限。工作流在返回已验证的 GIF 绝对路径后即结束;上传产物以及修改 PR、issue 或文档仍属于独立的工作流。 +随附的 `encode_gif.py` 辅助脚本按词法顺序排列各帧,为每帧设置明确的停留时长,通过 `ffmpeg` 调色板流水线编码,并借助 `ffprobe` 校验源图像尺寸以及编码结果的帧数、尺寸、时长和字节上限。录制在返回已验证的 GIF 绝对路径后即结束;当任务包含把 GIF 附到 PR 时,[GUI PR 的 GIF 证据决策](2026-07-26-gui-pr-gif-evidence-and-assets-branch.md)拥有强制证据政策以及随后的 assets 分支发布步骤。 ## 曾考虑的替代方案 @@ -20,7 +20,7 @@ Status: implemented **在 skill 中保留内联 `ffmpeg` 配方。**每次运行都重新组装引号转义、时序清单、调色板过滤器、覆盖行为和编码后检查,容易出错。随附的辅助脚本使这些机制保持可执行,skill 则负责判断何时截取画面。 -**纳入 GitHub 附件上传与描述编辑。**上传和远程修改需要各自独立的身份认证、确认与恢复规则。将它们排除在外,可以使录制 skill 的调用保持本地且可撤销。 +**纳入 GitHub 附件上传与描述编辑。**上传和远程修改需要各自独立的身份认证、确认与恢复规则。让录制本身保持本地且可撤销即维护了这一边界;对确需把 GIF 附到 PR 的任务,[GUI PR 的 GIF 证据决策](2026-07-26-gui-pr-gif-evidence-and-assets-branch.md)拥有那个有边界的发布步骤。 **每当 fixture 更容易布置时就使用它。**当请求明确要求由 fixture 支撑演示时,使用 fixture 是有效的;但它无法为真实服务器或真实 API 的声明提供证据。该 skill 会保持请求指定的演示来源,并在缺少先决条件时报告问题,不会擅自更改来源。 diff --git a/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml new file mode 100644 index 0000000000..cd359dbe3e --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.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-gui-pr-gif-evidence-and-assets-branch.md: c75b88cd9b4580217857c1fd730b8b335200680d +2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md: 6f2fdd1d8211661e780197b23a28688dab68ef50 diff --git a/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md b/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md new file mode 100644 index 0000000000..c75b88cd9b --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md @@ -0,0 +1,39 @@ +# Agent Note: GUI pull request GIF evidence and assets-branch publication + +Status: implemented + +English | [中文](2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md) + +## Problem + +A pull request that changes what a product user sees in the GUI is otherwise reviewed through prose and test names, neither of which shows the rendered result. The [browser-demo GIF recording](2026-07-23-browser-demo-gif-recording.md) skill produces truthful local GIFs but deliberately stopped at the local artifact, so each pull request that wanted to show one re-derived publication on its own — and committing the GIF to the pull request branch is never acceptable, because binary media in history bloats every future clone permanently. + +The recording procedure itself also kept being re-learned failure by failure: screenshots written outside the browser tool's allowed roots or into missing directories fail at capture time, transient UI states polled across separate tool calls are lost because the turn settles between calls, substring completion predicates match the echo of the user's own prompt, and an inline environment-variable assignment on the encoder command expands too late to take effect. + +## Decision + +Every pull request that changes product-user-visible GUI behavior includes a demonstration GIF recorded with the [record-browser-gif skill](../../../skills/record-browser-gif/SKILL.md), with real provenance — a real server booted from that pull request's own branch tree, a real API key, and real model rounds — stated next to the embed. Fixture provenance is acceptable only when the user explicitly asked for it. + +The GIF is published to a dedicated orphan assets branch — no parent commit, media only — never to the pull request branch; one assets branch serves a whole pull request series (existing branches: `code-mode-ui-assets`, `pr-613-assets`). Publication works in a shallow single-branch scratch clone, commits as `assets: gif (#)`, and the pull request body embeds the blob URL with the required `?raw=true` suffix. Assets branches are append-only: merged pull request bodies reference their URLs forever, so an assets branch is never rewritten or deleted. + +Recording itself stays side-effect-free; publication is a bounded final step the skill performs only when the task includes attaching the GIF to a pull request. This amends the recording/upload boundary recorded in the [browser-demo GIF recording note](2026-07-23-browser-demo-gif-recording.md), which stays current for the recording half. + +The skill folds in the operational lessons recording earned: frames go under `.playwright-mcp/`, ignored by the repository `.gitignore` and created before capture, because the browser tool writes only under its allowed roots and resolves relative names against the repository root; each pull request stages its own built tree with a fresh scratch workspace and a new session per scenario, and servers are stopped by PID rather than a broad process-name pattern; transient states are captured by driving a slow foreground operation and polling a concrete DOM marker inside one browser-script call; completion predicates match an exact-text element rather than a substring; and the encoder runs with `GIF_SKILL_DIR` exported on its own line, per-frame durations holding the settled state longest, and both a JSON-summary check and a visual read of the encoded GIF. + +## Alternatives considered + +**Commit the GIF to the pull request branch.** Binary media merged into the default branch stays in history for every future clone and fetch; a demo GIF's value ends at review while its cost never does. + +**Attach the GIF as a GitHub upload.** Drag-and-drop `user-attachments` uploads are not available to a command-line workflow, cannot be re-created or audited from the repository, and leave the media's lifecycle outside repository control. + +**Store GIFs with Git LFS.** LFS still couples media to the code branch's history, adds an infrastructure dependency to every clone and CI fetch, and buys nothing over an isolated branch that ordinary git already supports. + +**One assets branch per pull request.** A branch per pull request sprawls the ref namespace and multiplies scratch clones during a series; one branch per series keeps publication a single push while staying isolated from code history. + +**Keep publication out of the recording skill.** That was the prior state; it preserved a clean boundary but made every pull request re-derive the same procedure. The boundary survives as an explicit gate — publication runs only when the task includes attaching the GIF to a pull request — instead of as omission. + +**Leave the GIF optional per pull request.** Optional evidence disappears under schedule pressure exactly where it matters most; a GUI change reviewed without a recording asks reviewers to imagine the rendered result or rebuild the branch themselves. + +## Consequences + +Every GUI pull request carries visual evidence with stated provenance, and reviewers see the change without rebuilding the branch. Repository history stays free of media; the cost moves to append-only assets branches that grow forever, stay cheap to clone shallowly, and can never be deleted. Mandatory real-provenance recording adds a real-key, real-model round to every GUI pull request's workflow — deliberate, because that run is the evidence. The recording half remains locally reversible, and a GIF request whose task does not include attaching it to a pull request still ends at the verified local artifact. diff --git a/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md b/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md new file mode 100644 index 0000000000..6f2fdd1d82 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md @@ -0,0 +1,39 @@ +# Agent Note: GUI PR 的 GIF 证据与 assets 分支发布 + +Status: implemented + +[English](2026-07-26-gui-pr-gif-evidence-and-assets-branch.md) | 中文 + +## 问题 + +改变产品用户在 GUI 中所见行为的 PR(Pull Request),此前只能通过文字描述和测试名称接受评审,两者都无法展示渲染结果。[浏览器演示 GIF 录制](2026-07-23-browser-demo-gif-recording.md)对应的 skill(技能)能生成真实可信的本地 GIF,但刻意止步于本地产物,于是每个想展示 GIF 的 PR 都得各自重新摸索发布方式;而把 GIF 提交到 PR 分支从来不可接受:进入历史的二进制媒体会永久增大之后每一次克隆的体积。 + +录制流程本身也在靠一次次失败反复重新学习:截图写到浏览器工具允许的根目录之外或写入不存在的目录,会在截取时直接失败;跨多次工具调用轮询的瞬态 UI 状态会丢失,因为调用之间轮次已经结算;用子串匹配做完成判定会命中用户自己提示词的回显;在编码器命令上内联赋值环境变量则因参数先于赋值展开而不生效。 + +## 决策 + +每个改变产品用户可见 GUI 行为的 PR 都包含一个用 [record-browser-gif skill](../../../skills/record-browser-gif/SKILL.md) 录制的演示 GIF,其来源必须真实:从该 PR 自身分支树启动的真实服务器、真实 API 密钥、真实的模型轮次,并在嵌入处注明来源。只有当用户明确要求 fixture(测试前置数据)来源时才可使用 fixture。 + +GIF 发布到专用的孤儿(orphan)assets 分支上:该分支没有父提交、只含媒体,GIF 绝不进入 PR 自己的分支;一个 assets 分支服务整个 PR 系列(现有分支:`code-mode-ui-assets`、`pr-613-assets`)。发布在浅层单分支的临时克隆中进行,提交信息形如 `assets: gif (#)`,PR 正文用带必需 `?raw=true` 后缀的 blob URL 嵌入。assets 分支只允许追加:已合并的 PR 正文会永远引用其 URL,因此 assets 分支绝不重写或删除。 + +录制本身保持无副作用;发布是一个有边界的收尾步骤,仅当任务包含把 GIF 附到 PR 时才由该 skill 执行。这修订了[浏览器演示 GIF 录制记录](2026-07-23-browser-demo-gif-recording.md)中记录的录制/上传边界;录制部分仍以该记录为准。 + +该 skill 还吸收了录制实践换来的操作经验:帧文件放在仓库 `.gitignore` 忽略的 `.playwright-mcp/` 目录下并在截取前先创建,因为浏览器工具只能写入其允许的根目录,相对文件名也相对仓库根目录解析;每个 PR 从自己构建的分支树启动服务,配以全新的临时工作区目录,每个录制场景新开会话,停止服务器时按 PID 精确匹配而不是用宽泛的进程名模式;瞬态状态靠驱动一个缓慢的前台操作、并在同一次浏览器脚本调用内轮询具体的 DOM 标记来截取;完成判定匹配精确文本元素而非子串;编码器在单独一行 export `GIF_SKILL_DIR` 之后运行,逐帧时长让最终稳定状态停留最久,并同时核对 JSON 摘要与目视检查编码后的 GIF。 + +## 曾考虑的替代方案 + +**把 GIF 提交到 PR 分支。**合入默认分支的二进制媒体会留在历史中,影响之后的每一次克隆和拉取;演示 GIF 的价值止于评审,代价却永不消失。 + +**作为 GitHub 附件上传。**拖拽产生的 `user-attachments` 上传对命令行工作流不可用,无法从仓库重建或审计,媒体的生命周期也脱离仓库的控制。 + +**用 Git LFS 存储 GIF。**LFS 仍把媒体耦合进代码分支的历史,给每次克隆和 CI 拉取增加一项基础设施依赖,相比普通 git 即可支持的隔离分支没有任何额外收益。 + +**每个 PR 一个 assets 分支。**按 PR 建分支会让 ref 命名空间蔓延,并在一个系列内成倍增加临时克隆;每个系列一个分支让发布只需一次推送,同时仍与代码历史隔离。 + +**把发布留在录制 skill 之外。**这是此前的状态;它保住了干净的边界,却让每个 PR 重新摸索同一套流程。这个边界如今以显式条件的形式保留:仅当任务包含把 GIF 附到 PR 时才执行发布,而不是靠省略来体现。 + +**让 GIF 在每个 PR 中保持可选。**可选的证据恰恰会在最需要它的进度压力下消失;没有录制的 GUI 变更评审,等于要求评审人自行想象渲染结果或重新构建分支。 + +## 后果 + +每个 GUI PR 都携带注明来源的可视证据,评审人无需重新构建分支即可看到变更。仓库历史保持不含媒体;代价转移到只追加的 assets 分支上:它们会持续增长、可以低成本地浅克隆、且永远不能删除。强制的真实来源录制给每个 GUI PR 的工作流增加一次真实密钥、真实模型轮次的运行,这是有意为之,因为这次运行本身就是证据。录制部分仍然在本地可撤销;任务不包含附到 PR 的 GIF 请求,仍以已验证的本地产物结束。 diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md index e48e16ca40..074b8b176e 100644 --- a/.agents/skills/record-browser-gif/SKILL.md +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -1,27 +1,46 @@ --- name: record-browser-gif -description: Record browser or Web UI interaction demos as optimized local GIFs using the available built-in browser, state-based frame capture, and deterministic encoding. Use when Codex is asked to make, record, or generate a GIF that demonstrates a browser workflow, including real-server or real-API behavior. Stop after returning the verified local artifact; do not upload it or edit a pull request. +description: Record browser or Web UI interaction demos as optimized GIFs using the available built-in browser, state-based frame capture, and deterministic encoding, then publish to a dedicated assets branch when the task includes attaching the GIF to a pull request. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include such a GIF with real provenance. --- # Record Browser GIF -Produce a short, truthful UI demonstration as a local GIF. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size. +Produce a short, truthful UI demonstration as a local GIF, and — only when the task includes attaching it to a pull request — publish it through the assets-branch workflow at the end of this skill. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size. + +## Every GUI pull request includes a GIF + +A pull request that changes product-user-visible GUI behavior MUST include a demonstration GIF recorded with this skill and embedded in the pull request body via [the assets-branch workflow](#publish-to-an-assets-branch). + +The GIF's provenance is part of the evidence and must be real: a real server booted from that pull request's own branch tree, a real API key, and real model rounds. Never substitute fixture queries, mock transports, synthetic event injection, or test-only hooks unless the user explicitly asked for fixture provenance. State the provenance next to the embed — which tree served, which mode flags, that a real model round ran — so reviewers know exactly what the recording proves. ## Keep the boundary explicit -- Produce frame images and one local `.gif` artifact only. -- Never upload the artifact, post a comment, or change a pull request, issue, or document under this skill. Hand those actions to a separate workflow if the user requests them. +- Recording produces frame images and one local `.gif` artifact only; it never mutates remote state. +- Publication — pushing the GIF to an assets branch and embedding it in a pull request body — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch. - Preserve the requested provenance. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture. - Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt. +## Stage the application + +A GIF for a specific pull request demonstrates that pull request's tree, so stage per pull request: + +1. Build the branch tree being demonstrated — here, `pnpm run build && pnpm run build:web` — from the worktree that holds that branch. A GIF recorded against another branch's build misattributes the evidence. +2. Boot one server per port from that tree, giving each recording a fresh scratch workspace directory so leftover sessions cannot appear in frames. Source the root `.env` for the API key through the application's normal path; never echo the key. +3. Start a new session for each recorded scenario so earlier turns do not pollute the frames. +4. When switching between pull requests, stop the old server by PID or an exact match on its command line. A broad `pkill -f` pattern can match and kill the shell that launched it — including your own. + ## Record the flow 1. Invoke the available browser-control skill and follow its setup, interaction, and cleanup instructions. Use the user's existing Chrome state only when requested or required. 2. Resolve the evidence boundary before recording: identify the exact origin, whether the app is built or in development, the transport, and any fixture or mock mode. Record only claims that the observed setup supports. -3. Choose three to six states that tell one story, such as initial, typed, submitted, and completed. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer. -4. Keep one viewport and crop for every frame. Store frames in an absolute artifact directory outside the Git worktree unless the user requests another location, and name them lexically: `00-initial.png`, `01-typed.png`, and so on. -5. Before each screenshot, wait for a concrete UI condition such as a unique label, enabled control, changed document title, or completed response. Do not use a fixed delay as proof that the application reached the state. -6. Capture no secrets, personal data, unrelated tabs, or transient notifications. Stop any unnecessarily long real-API run after the demonstrated state is visible. +3. Choose three to six states that tell one story, such as typed, running, settled, and detail. Prefer semantic state changes over continuous capture; omit loading churn that does not help the viewer. +4. Keep one viewport and crop for every frame, and name frames lexically: `00-initial.png`, `01-typed.png`, and so on. +5. Store frames under the repository's gitignored `.playwright-mcp/` directory — browser-tool screenshots can only be written under the tool's allowed roots, and relative filenames resolve against the repository root. Create the frame subdirectory first (`mkdir -p .playwright-mcp/gif-frames-