From 8de6df19d9763491b0a2c47f026909853ae9ed2a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 18:37:30 +0800 Subject: [PATCH 01/25] feat(workflow): show durable run records in Chat --- .../2026-07-05-dynamic-workflows.i18n.yaml | 4 +- .../feature/2026-07-05-dynamic-workflows.md | 5 +- .../2026-07-05-dynamic-workflows.zh.md | 5 +- ...10-durable-workflow-runs-in-chat.i18n.yaml | 6 + ...026-08-10-durable-workflow-runs-in-chat.md | 45 ++ ...-08-10-durable-workflow-runs-in-chat.zh.md | 45 ++ ...apse-workflow-to-foreground-core.i18n.yaml | 4 +- ...12-collapse-workflow-to-foreground-core.md | 12 +- ...collapse-workflow-to-foreground-core.zh.md | 12 +- apps/web/tests/assembled-boot.ts | 1 + .../snapshots/workflow-run/ui.expected.md | 55 ++ apps/web/tests/workflow-run.e2e.ts | 170 ++++++ apps/web/tsconfig.json | 3 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 3 +- docs/config-catalog.zh.md | 3 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 18 +- docs/event-producer-consumer.zh.md | 20 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 14 +- docs/module-graph.zh.md | 14 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 50 ++ docs/persistence-catalog.zh.md | 50 ++ docs/subsystems/workflow.i18n.yaml | 4 +- docs/subsystems/workflow.md | 60 +- docs/subsystems/workflow.zh.md | 60 +- knip.json | 10 + packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + .../client/ui-workflow-run/README.i18n.yaml | 6 + packages/client/ui-workflow-run/README.md | 35 ++ packages/client/ui-workflow-run/README.zh.md | 35 ++ packages/client/ui-workflow-run/package.json | 73 +++ .../src/client/WorkflowRunPanel.module.css | 250 +++++++++ .../src/client/WorkflowRunPanel.tsx | 235 ++++++++ .../ui-workflow-run/src/client/index.ts | 38 ++ .../ui-workflow-run/src/client/locales.ts | 49 ++ .../src/client/workflow-definition.ts | 200 +++++++ .../ui-workflow-run/src/css-modules.d.ts | 6 + packages/client/ui-workflow-run/src/index.ts | 4 + .../client/ui-workflow-run/src/invariant.ts | 24 + .../tests/workflow-run.spec.tsx | 526 ++++++++++++++++++ packages/client/ui-workflow-run/tsconfig.json | 42 ++ .../client/ui-workflow-run/tsdown.config.ts | 3 + .../workflow/tool-workflow/README.i18n.yaml | 4 +- packages/workflow/tool-workflow/README.md | 5 + packages/workflow/tool-workflow/README.zh.md | 5 + packages/workflow/tool-workflow/package.json | 6 + packages/workflow/tool-workflow/src/index.ts | 157 +++++- .../workflow/tool-workflow/src/invariant.ts | 164 +++++- packages/workflow/tool-workflow/src/types.ts | 64 +++ .../tool-workflow/tests/invariant.spec.ts | 199 +++++++ .../tool-workflow/tests/tool-workflow.spec.ts | 225 +++++++- packages/workflow/tool-workflow/tsconfig.json | 3 + packages/workflow/workflow/README.i18n.yaml | 4 +- packages/workflow/workflow/README.md | 2 + packages/workflow/workflow/README.zh.md | 2 + packages/workflow/workflow/package.json | 5 + packages/workflow/workflow/src/index.ts | 6 +- .../workflow/workflow/src/runtime-types.ts | 49 ++ packages/workflow/workflow/src/types.ts | 54 +- packages/workflow/workflow/tsconfig.json | 3 + pnpm-lock.yaml | 46 ++ scripts/type-equiv.manifest.json | 4 +- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 73 files changed, 3013 insertions(+), 227 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md create mode 100644 apps/web/tests/snapshots/workflow-run/ui.expected.md create mode 100644 apps/web/tests/workflow-run.e2e.ts create mode 100644 packages/client/ui-workflow-run/README.i18n.yaml create mode 100644 packages/client/ui-workflow-run/README.md create mode 100644 packages/client/ui-workflow-run/README.zh.md create mode 100644 packages/client/ui-workflow-run/package.json create mode 100644 packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css create mode 100644 packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx create mode 100644 packages/client/ui-workflow-run/src/client/index.ts create mode 100644 packages/client/ui-workflow-run/src/client/locales.ts create mode 100644 packages/client/ui-workflow-run/src/client/workflow-definition.ts create mode 100644 packages/client/ui-workflow-run/src/css-modules.d.ts create mode 100644 packages/client/ui-workflow-run/src/index.ts create mode 100644 packages/client/ui-workflow-run/src/invariant.ts create mode 100644 packages/client/ui-workflow-run/tests/workflow-run.spec.tsx create mode 100644 packages/client/ui-workflow-run/tsconfig.json create mode 100644 packages/client/ui-workflow-run/tsdown.config.ts create mode 100644 packages/workflow/tool-workflow/src/types.ts create mode 100644 packages/workflow/tool-workflow/tests/invariant.spec.ts create mode 100644 packages/workflow/workflow/src/runtime-types.ts diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml index d5ff80bd97..2760ff3361 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.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 .agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md -2026-07-05-dynamic-workflows.md: 3e491478286eb77b56872fcbbdd5ebb6b62a5545 -2026-07-05-dynamic-workflows.zh.md: 7888d83f981a96ac5eb31d5ca6f1f8d0b4930ec7 +2026-07-05-dynamic-workflows.md: 287b0031a5fecaaa815befa3c7b792c3179f1dae +2026-07-05-dynamic-workflows.zh.md: 8b63498fd7f82159bfc0cc3b5d29f3a151d84338 diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md index 3e49147828..287b0031a5 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md @@ -40,6 +40,8 @@ The engine exposes an in-process `MessageChannel` test path because main-process A `workflow` tool mirroring `dsh-tool-subagent`'s synchronous shape: start, await, `try/finally` dispose, abort-bridge `exec.signal`, non-`completed` → `isError`. Render intent: a `generic` card titled by the call's `meta.name` parameter (presentation is a pure function of args). The tool description IS the model-facing authoring spec. The usage policy ships with the tool as its own `tool:` prompt section (explicit-ask-only guidance — tool guidance lives in tool plugins, never in the deployment persona); the harness has no ultracode-style effort gate. +For a top-level tool execution, the same consumer also writes the run and actual member lifecycle into the calling parent Session as four log-only `tool-workflow/*` events. The recording path observes rather than controls execution: its first append failure disables later writes for that run and leaves a legal prefix without changing the tool result. [`ui-workflow-run`](../../../../packages/client/ui-workflow-run/README.md) rebuilds those facts through the Conversation Node engine as a separate keyed Chat row; the existing generic tool row remains its own presentation owner. The detailed persistence, replay, disclosure, and live-navigation decision lives in [durable workflow runs in Chat](2026-08-10-durable-workflow-runs-in-chat.md). + ### The foundation: structured output on the subagent seam `SubagentStartRequest.outputSchema` is implemented by `dsh-subagent-inprocess` for both in-process backends. Each structured child receives its own scoped capture tool, instruction, and enforcement registrations on `child.ctx`; concurrent children can use different schemas without sharing mutable policy, and disposing the child removes the entire attachment. @@ -60,7 +62,6 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea - **Nested `workflow()`**, **token `budget`**, and the `effort`/`isolation`/`agentType` agent options (each rejects loud with a message naming it deferred). - **An overall run wall-clock timeout** — cancellation always frees the caller (result settles within the grace), so a cap on total run time is a policy knob for the background redesign, not a correctness need here. - **Engine hardening beyond worker threads**: an isolated-vm or separate-process engine behind the same seam (actual sandboxing; memory limits). -- **Human-interface progress UI** over the `workflow/*` events (a `/workflows`-style view); the events exist for it. - **ACP-backend structured output** and **`toolFilter`** (both still capability-gated `false`). ## Alternatives considered @@ -77,4 +78,4 @@ Worker-side logic runs through an in-process `MessageChannel` so V8 coverage mea ## Consequences -Fan-out plans now live in rerunnable scripts, and `outputSchema` provides authoritative structured child results. Each run pays worker startup and message-port RPC costs, but host startup stays non-blocking, cancellation can terminate the worker, and serialization enforces the value boundary. Worker threads are not a security boundary. Invalid options fail rather than degrading to Claude Code's `null`; consumers retain control through the run handle while observers receive snapshots only. +Fan-out plans now live in rerunnable scripts, and `outputSchema` provides authoritative structured child results. Each run pays worker startup and message-port RPC costs, but host startup stays non-blocking, cancellation can terminate the worker, and serialization enforces the value boundary. Worker threads are not a security boundary. Invalid options fail rather than degrading to Claude Code's `null`; consumers retain control through the run handle while observers receive snapshots only. Top-level Web users also receive a durable, replayable workflow record without widening the execution seam or coupling the original tool card to workflow-specific UI. diff --git a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md index 7888d83f98..8b63498fd7 100644 --- a/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.zh.md @@ -40,6 +40,8 @@ harness 可以将一个任务委派给一个子 agent(`dsh-tool-subagent`) 一个 `workflow` 工具,镜像 `dsh-tool-subagent` 的同步形态:启动、await、`try/finally` dispose、abort 桥接 `exec.signal`、非 `completed` → `isError`。渲染意图:一张以调用的 `meta.name` 参数为标题的 `generic` 卡片(展示是参数的纯函数)。工具描述即面向模型的编写规范。使用策略以工具自身的 `tool:` 提示词段落随工具发布(显式请求才使用的引导——工具引导存在于工具插件中,从不在部署 persona 中);harness 没有 ultracode 风格的 effort 门控。 +对于顶层工具执行,同一消费方还会把运行及真正开始过的成员生命周期写入调用方父 Session,形成四类 log-only `tool-workflow/*` 事件。记录路径只观察、不控制执行:第一次 append 失败会禁用本运行后续写入并留下合法前缀,不改变工具结果。[`ui-workflow-run`](../../../../packages/client/ui-workflow-run/README.md) 通过 Conversation Node 引擎重建这些事实,形成独立 keyed Chat 行;现有 generic 工具行继续拥有自己的展示。持久化、回放、折叠与实时导航的详细决策见 [Chat 中的持久工作流运行](2026-08-10-durable-workflow-runs-in-chat.md)。 + ### 基础:subagent seam 上的结构化输出 `SubagentStartRequest.outputSchema` 由 `dsh-subagent-inprocess` 为两个进程内后端实现。每个结构化子 agent 在 `child.ctx` 上获得自己的作用域捕获工具、指令和强制注册;并发子 agent 可以使用不同的 schema 而不共享可变策略,dispose 子 agent 时移除整个附件。 @@ -60,7 +62,6 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够 - **嵌套 `workflow()`**、**token `budget`**,以及 `effort`/`isolation`/`agentType` agent 选项(每个都会明确拒绝,并在消息中注明其已延迟实现)。 - **整体运行的挂钟超时**:取消总能释放调用方(result 在宽限期内 settle),因此总运行时间上限是后台重设计的策略旋钮,不是此处的正确性需求。 - **超越 worker 线程的引擎加固**:在同一 seam 背后使用 isolated-vm 或独立进程引擎(真正的沙箱化;内存限制)。 -- **面向人类界面的进度 UI**(基于 `workflow/*` 事件的 `/workflows` 风格视图);事件已为此而存在。 - **ACP(Agent Client Protocol)后端结构化输出**和 **`toolFilter`**(两者仍以能力标志 `false` 门控)。 ## 曾考虑的替代方案 @@ -77,4 +78,4 @@ worker 侧逻辑通过进程内 `MessageChannel` 运行,使 V8 覆盖率能够 ## 后果 -扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和消息端口 RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项会失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。 +扇出计划现在存在于可重运行的脚本中,`outputSchema` 提供权威的结构化子 agent 结果。每次运行付出 worker 启动和消息端口 RPC 成本,但宿主启动保持非阻塞,取消可以终止 worker,序列化强制执行值边界。worker 线程不是安全边界。无效选项会失败而非退化为 Claude Code 的 `null`;消费方通过 run handle 保持控制权,观察者仅接收快照。顶层 Web 用户还会得到持久、可回放的工作流记录,同时不扩宽执行 seam,也不把原工具卡耦合到工作流专属 UI。 diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml new file mode 100644 index 0000000000..4b44acecd9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.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 .agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md +2026-08-10-durable-workflow-runs-in-chat.md: 791a81e9e304a11f45557197ac1f97184132ccab +2026-08-10-durable-workflow-runs-in-chat.zh.md: e6c87f61a144cebc0282055c8ae315d9068616fd diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md new file mode 100644 index 0000000000..791a81e9e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md @@ -0,0 +1,45 @@ +# Agent Note: Durable workflow runs in Chat + +Status: implemented + +English | [中文](2026-08-10-durable-workflow-runs-in-chat.zh.md) + +## Problem + +The ordinary workflow tool row owns the model call and final tool result, but those two records do not explain which members actually started, how they were grouped, whether each member completed, failed, or was cancelled, or what remained unfinished when a process stopped. Live `workflow/*` events expose those facts only inside the current process, so a refresh or later Session open loses the run history. + +The Web Client already assembles business-owned Conversation Nodes from durable Session events. Workflow history therefore needs a producer that can correlate one accepted run with its calling Session, a minimal durable protocol that remains meaningful as a prefix, and an independent renderer that does not take ownership away from the existing tool card. + +## Decision + +`dsh-tool-workflow` projects every top-level accepted run into the calling Agent's Session. `tool-workflow/run-start` records the stable `runId` and validated name; matching workflow member events record the member sequence, exact label, optional exact phase, child Session id, and outcome; `tool-workflow/run-end` records the stop reason only after the result exists and `run.dispose()` has reached quiescence. Nested transport executions run normally but write no workflow record because they do not own an independent Chat row. + +Recording is observational. The first failed Session append disables all later writes for that run, logs one warning, and never changes cancellation, result mapping, or disposal. Each possible failure leaves either no record or a legal continuous prefix: a started run may lack later members or its ending, and a started member may lack its ending. The package invariant rejects duplicate run starts, invalid or reused positive member sequences, unpaired or repeated member endings, a run ending while members remain open, and every update after a run ending on both cold load and live append. + +The workflow package exposes browser-safe run and observation vocabulary through `@deepseek-ai/dsh-workflow/types`; live `Agent` requests and control handles remain Host-only. `@deepseek-ai/dsh-tool-workflow/types` owns the four Session events. Client code imports only these type faces, so the Host and Client TypeScript programs share the durable contract without merging Host Cordis context. + +`ui-workflow-run` registers one `workflow-run` Conversation Definition and one keyed Chat renderer. Every event independently yields the same `runId`; run-start initializes State, later events update it in log order, and an update-only history tail remains pending until prepend supplies the unique start. The final node keeps the engine-owned key and anchors at run-start, placing it after the original tool call while preserving one React parent from running through terminal state. + +The renderer gives each level a distinct visual responsibility. The run uses a 32-pixel module-platform background row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. Phases exist only when a member actually starts and group by the exact phase string; an omitted phase and the empty string retain distinct identities and localized names. Member settlement changes status without removing or reordering the member. A closed Turn or Step turns missing run or member endings into interrupted presentation; a durable ending remains authoritative when present. + +Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static. + +The [seven-state Figma reference](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2) fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner. + +## Verification + +Package tests cover top-level and nested eligibility, zero-member and concurrent runs, disposal-before-ending order, all four append-failure prefixes, and cold/live invariant rejection. Conversation tests compare complete replace, update-only prepend, and live append; they cover exact phase identity, terminal and interrupted status, disclosure state, list-fact navigation, and HMR removal and re-registration. The shipped Web replay uses the existing workflow parent and child model fixtures to exercise the real worker, spawn provider, Session persistence, browser bundle, running child navigation, terminal retention, original tool-row coexistence, narrow dark tokens, and refresh reconstruction. + +## Alternatives considered + +**Append workflow content inside the existing tool card.** Rejected because `ui-tool` and the tool definition own that row's presentation and interaction. A workflow-specific appendix would couple two independently keyed business lifecycles and revive the removed post-tool attachment model. + +**Persist a server-side projection or add a workflow wire channel.** Rejected because Session events already provide persistence, live delivery, pagination, and gap repair. Another service, cache, or transport would duplicate the same facts and create a second lifecycle owner. + +**Render declared phases or infer a static workflow graph from script text.** Rejected because only member-start events prove work happened. `meta.phases`, `phase()` narration, branches, and script syntax do not describe one authoritative runtime topology. + +**Keep terminal child navigation.** Rejected because the workflow record proves historical identity, not current accessibility. Cold or remote Session opening needs a separate catalog and authorization contract; this node grants no such promise. + +## Consequences + +Workflow progress survives refresh and process recovery in the same log as its parent conversation, while execution ownership remains with the workflow run holder and the original tool card remains unchanged. The durable protocol adds four small events and one package-owned invariant; first-write failure intentionally sacrifices later observation rather than workflow correctness. Browser State is derived per loaded window, disclosure choices remain local, and navigation can disappear as list facts change. The design shows only actual runtime members and statuses, giving up static graph visualization, outputs, logs, controls, and terminal-member opening. diff --git a/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md new file mode 100644 index 0000000000..e6c87f61a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md @@ -0,0 +1,45 @@ +# Agent Note: Chat 中的持久工作流运行 + +Status: implemented + +[English](2026-08-10-durable-workflow-runs-in-chat.md) | 中文 + +## 问题 + +普通工作流工具行拥有模型调用与最终工具结果,但这两条记录无法说明哪些成员真正开始、如何分组、各成员是完成、失败还是取消,也无法说明进程停止时哪些工作尚未结束。实时 `workflow/*` 事件只存在于当前进程,因此刷新或稍后重新打开 Session 会丢失运行历史。 + +Web Client 已经能够从持久 Session 事件组装由业务拥有的 Conversation Node。工作流历史因此需要:能够把一次已接受运行关联到调用 Session 的生产方、作为前缀也始终有意义的最小持久协议,以及不夺走现有工具卡所有权的独立 renderer。 + +## 决策 + +`dsh-tool-workflow` 把每个已接受的顶层运行投影到调用 Agent 的 Session。`tool-workflow/run-start` 记录稳定 `runId` 与已校验名称;匹配的工作流成员事件记录成员序号、精确标签、可选精确阶段、子 Session id 与结果;只有在结果已取得且 `run.dispose()` 完全停稳后,`tool-workflow/run-end` 才记录停止原因。嵌套 transport 执行照常运行,但不会写工作流记录,因为它不拥有独立 Chat 行。 + +记录只供观察。任一次 Session append 首次失败后,本运行会停止所有后续写入、只记录一次告警,并且绝不改变取消、结果映射或 dispose。每种失败位置都留下空记录或合法连续前缀:已开始运行可以缺少后续成员或运行终点,已开始成员也可以缺少成员终点。包 invariant 会在冷加载与实时 append 时拒绝重复运行 start、无效或复用的正成员序号、无配对或重复成员 end、仍有开放成员时结束运行,以及运行结束后的任何更新。 + +workflow 包通过 `@deepseek-ai/dsh-workflow/types` 提供浏览器安全的运行与观察词汇;包含活跃 `Agent` 的请求和控制句柄继续只属于 Host。`@deepseek-ai/dsh-tool-workflow/types` 拥有四类 Session 事件。Client 只导入这些类型 face,因此 Host 与 Client TypeScript 程序共享持久合同,而不会合并 Host Cordis Context。 + +`ui-workflow-run` 注册一个 `workflow-run` Conversation Definition 和一个 keyed Chat renderer。每条事件都能独立给出同一 `runId`;run-start 初始化 State,后续事件按日志顺序更新;只有 update 的历史尾页会保持 pending,直到 prepend 补入唯一 start。最终节点保留引擎拥有的 key,并以 run-start 锚定在原工具调用之后,从运行中到终态始终保留同一个 React 父级。 + +renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-platform 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。阶段只在成员真正开始时出现,并按精确阶段字符串分组;字段缺省与空字符串保留不同身份和本地化名称。成员结算只改变状态,不删除或重排成员。所属 Turn 或 Step 关闭时,缺少运行或成员终点会显示为已中断;存在持久终点时仍以它为权威。 + +导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'`、`parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,固定状态列继续只表达生命周期,而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。 + +[七状态 Figma 参考](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2)固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow`、`StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。 + +## 验证 + +包测试覆盖顶层与嵌套准入、零成员与并发运行、先 dispose 后写终点的顺序、四个 append 失败前缀,以及冷/实时 invariant 拒绝。Conversation 测试比较完整 replace、只有 update 的 prepend 和实时 append,并覆盖精确阶段身份、终态与中断状态、disclosure 状态、列表事实导航、HMR 移除与重新注册。shipped Web replay 复用现有工作流父/子模型 fixture,驱动真实 worker、spawn provider、Session 持久化、浏览器 bundle、运行中子级导航、终态保留、原工具行并存、暗色窄列 token 与刷新重建。 + +## 曾考虑的替代方案 + +**把工作流内容附加到现有工具卡。** 拒绝,因为 `ui-tool` 与工具定义拥有该行的展示和交互。工作流专属 appendix 会耦合两个独立 keyed 业务生命周期,并恢复已移除的工具后附加模型。 + +**持久化服务端 projection 或新增 workflow wire 通道。** 拒绝,因为 Session 事件已经提供持久化、实时传输、分页和 gap repair。另一个 service、cache 或 transport 会复制同一事实并建立第二个生命周期 owner。 + +**展示声明阶段,或从脚本文本推断静态工作流图。** 拒绝,因为只有成员 start 事件能证明工作真正发生。`meta.phases`、`phase()` 叙述、分支和脚本语法都不是一次运行的权威拓扑。 + +**保留终态子级导航。** 拒绝,因为工作流记录证明历史身份,不证明当前可访问性。冷 Session 或远程 Session 的打开需要独立目录与授权合同;本节点不作这种承诺。 + +## 后果 + +工作流进度与父对话保存在同一日志中,能跨刷新与进程恢复;执行所有权仍属于工作流 run holder,原工具卡保持不变。持久协议增加四类小事件和一个包所有的 invariant;首次写入失败会刻意牺牲后续观察,而不是牺牲工作流正确性。浏览器 State 按已加载窗口派生,disclosure 选择保持本地,导航会随列表事实消失。设计只展示真实运行成员与状态,并放弃静态图、输出、日志、控制操作和终态成员打开。 diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index 9ade4e5770..cc9f18fbef 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.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 .agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md -2026-07-12-collapse-workflow-to-foreground-core.md: 5fc46584f83eb5307ff16f3353b56951b928aef3 -2026-07-12-collapse-workflow-to-foreground-core.zh.md: 0b4c73e5df973215b10166f3dc2bbd525cc8231b +2026-07-12-collapse-workflow-to-foreground-core.md: 9151d9fb72a97aadf040fbdc13b5e0a4943f2f30 +2026-07-12-collapse-workflow-to-foreground-core.zh.md: c9eafe83e931de7aec4ec39e2471f0669c73609d diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md index 5fc46584f8..9151d9fb72 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md @@ -6,15 +6,11 @@ English | [中文](2026-07-12-collapse-workflow-to-foreground-core.zh.md) ## Problem -The workflow capability executes foreground JavaScript that composes subagents, but it also carries an unconsumed progress-observation system. No production listener subscribes to any of the six `workflow/*` events; listeners exist only in workflow tests. Nevertheless the seam defines run/phase/agent outcome payloads, the worker sends phase/log/agent lifecycle protocol messages, the host forwards them through a `liveAgents` pairing ledger, and the engine maintains run ids solely to correlate those notifications. +The workflow capability carries an observe-only lifecycle beside its execution handle. That surface can look removable because the script still completes without a UI listener, but it is the only provider-neutral source of the actual members that started, their exact labels and phases, and their paired outcomes. -The progress vocabulary is not merely unused; it cannot serve its only named future owner without redesign. `WorkflowRunInfo` contains `{id, meta}` but no parent agent, session, or tool-call identity, while the model-facing tool never exposes the run id. A global ACP listener could not route an event to the correct client session. `meta.phases` is never consulted, `phase(title)` does not validate against it, phase `detail`/`model` and agent `label`/`phase` feed only events, and `whenToUse` is validated and copied but never rendered or selected. `phase()` and `log()` still cross the worker boundary despite having no receiver. +The top-level `dsh-tool-workflow` consumer now uses those events to write four minimal `tool-workflow/*` facts into the calling parent Session, and `ui-workflow-run` rebuilds them into a durable Chat node. The consumer deliberately owns the projection because it alone holds the calling Agent, knows whether the tool execution is top-level, and can keep recording failure separate from workflow execution. `WorkflowRun.id` and `meta` therefore correlate live engine events with that exact durable record rather than duplicating presentation state. -The live handle repeats event-era data after those observers disappear. `WorkflowRun.id` has no non-event consumer, while the tool reads `run.meta.name` only to render a value it already owns as `args.meta.name`; neither belongs on the execution/cancellation handle. - -Cancellation also has two public channels for one synchronous start. `WorkflowStartRequest.signal` is passed to the worker host, while the sole production caller separately bridges the same signal to `WorkflowRun.cancel()`. Because `start()` returns the run before control can yield, there is no readiness window that requires request-time cancellation; the duplicate signal adds host listener/disarm state without closing a race. - -`WorkflowError.fatal` is the same speculative branch in miniature: every production construction is fatal, `fatal: false` exists only in tests, and combinators already distinguish workflow failures with `instanceof`. +Deleting the event vocabulary, member labels or phases, or run identity would remove the current replay and navigation result rather than merely simplify unused scaffolding. The rejected proposal below remains useful as the contraction to avoid; [durable workflow runs in Chat](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md) owns the present consumer and boundaries. ## Proposal @@ -24,7 +20,7 @@ Amend the implemented dynamic-workflow Agent Note and update the seam/tool/worke ## Alternatives considered -**Keep the prebuilt observation vocabulary for a future UI.** The current shape resembles Claude Code dynamic-workflow metadata, and the host deliberately pairs each forwarded agent start with either the worker's end or a synthesized terminal end. Removing it gives up compatibility-by-shape and makes progress UI a new design task, but the existing payloads still lack routable ownership, so balanced lifecycles alone cannot make the named ACP owner viable without redesign. +**Move durable recording into the workflow engine.** The engine knows run and member lifecycle but does not own the calling parent Session or the top-level-versus-nested tool boundary. Giving it those facts would couple a provider seam to one consumer and make recording failure part of engine execution. The tool-owned projection adds the missing ownership without widening worker messages or the service contract. ## Acceptance criteria diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index 0b4c73e5df..c9eafe83e9 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -6,15 +6,11 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 问题 -工作流能力在前台执行用于编排 subagent 的 JavaScript,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent(智能体)outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 +工作流能力在执行句柄之外还携带一套只供观察的生命周期。脚本即使没有 UI 监听器也能完成,因此这套界面看似可删除;但它是唯一与提供方无关、能够报告真正开始过的成员、精确标签与阶段以及配对结果的事实来源。 -这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent、会话或工具调用标识,而面向模型的工具也从不暴露 run id。一个全局 ACP(Agent Client Protocol)监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 +顶层 `dsh-tool-workflow` 消费方现在利用这些事件,把四类最小 `tool-workflow/*` 事实写入调用方父 Session;`ui-workflow-run` 再把它们重建为持久 Chat 节点。投影由消费方拥有,因为只有它同时持有调用 Agent、知道工具执行是顶层还是嵌套,并能让记录故障与工作流执行隔离。`WorkflowRun.id` 与 `meta` 因此用于把实时引擎事件关联到该条精确持久记录,而不是复制展示状态。 -这些观测者移除后,live handle 仍重复携带事件机制所需的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 - -取消机制也为一个同步启动提供了两条公开通道。`WorkflowStartRequest.signal` 被传递给 worker host,而唯一的生产调用方另外将同一个 signal 桥接到 `WorkflowRun.cancel()`。因为 `start()` 在控制权让出之前就返回了 run,不存在需要请求时取消的就绪窗口;重复的 signal 增加了 host 的 listener/disarm 状态却没有封堵任何竞态。 - -`WorkflowError.fatal` 是同一种推测性分支的微缩版:所有生产环境的构造都是 fatal 的,`fatal: false` 仅存在于测试中,组合子已经通过 `instanceof` 区分工作流失败。 +删除事件词汇、成员标签或阶段、运行身份,会移除当前回放和导航结果,而不再只是清理未使用脚手架。下方提案继续记录应避免的收缩;[Chat 中的持久工作流运行](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md)拥有当前消费方与边界。 ## 提案 @@ -24,7 +20,7 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 曾考虑的替代方案 -**为未来 UI 保留预建的观测词汇。** 当前形态类似 Claude Code 的动态工作流元数据,host 有意地将每个转发的 agent start 与 worker 的 end 或一个合成的终止 end 配对。移除它意味着放弃形态兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让已命名的 ACP 消费方可行。 +**把持久记录移入工作流引擎。** 引擎知道运行与成员生命周期,却不拥有调用方父 Session,也不知道顶层与嵌套工具边界。把这些事实交给引擎会让提供方 seam 耦合到单一消费方,并使记录故障进入引擎执行域。由工具拥有的投影补齐了缺失所有权,同时不扩展 worker 消息或 service 合同。 ## 验收标准 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 631196c652..5883d62d72 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -27,6 +27,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', bundlePath: 'packages/client/ui-workspace/lib/client.js', diff --git a/apps/web/tests/snapshots/workflow-run/ui.expected.md b/apps/web/tests/snapshots/workflow-run/ui.expected.md new file mode 100644 index 0000000000..7a2e1cfd13 --- /dev/null +++ b/apps/web/tests/snapshots/workflow-run/ui.expected.md @@ -0,0 +1,55 @@ +- banner: + - navigation "Session hierarchy": + - button "Use the workflow tool exactly" [disabled] + - button "1 subagent": + - text: 1 subagent + - img + - img + - text: 标准模式 + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:": + - img + - img + - text: "Think The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:" +- button "Tool call workflow ·": + - img + - img + - text: Tool call workflow · +- button "snapshot-flow 1 members Completed" [expanded]: + - img + - text: snapshot-flow 1 members Completed +- button "Run 1 members Completed 1" [expanded]: + - img + - text: Run 1 members Completed 1 +- text: Reply with exactly the word WF_CHILD_OK and not… Completed +- button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.": + - img + - img + - text: Think The workflow returned successfully with the reply "WF_CHILD_OK". Now I need to reply with exactly "WORKFLOW_DONE" and stop. +- paragraph: WORKFLOW_DONE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- button "Back to bottom": + - img +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "3% of context used" +- button "Send message" [disabled] +- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 47% Input 6.6K tok · Output 227 tok diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts new file mode 100644 index 0000000000..cacefa75a4 --- /dev/null +++ b/apps/web/tests/workflow-run.e2e.ts @@ -0,0 +1,170 @@ +// Keyless shipped-Web acceptance for the durable workflow Conversation Node. +// Reuses the existing recorded workflow parent/child model fixtures; the real +// workflow tool, worker, subagent provider, Session log, browser plugin graph, +// and navigation all execute during replay. +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { + connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot, +} from './support.ts' + +const MODE = webSnapshotMode() +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workflow-run', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const PARENT_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.jsonl') +const CHILD_FIXTURE = join(REPO_ROOT, 'examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl') +const CHILD_PROMPT = 'Reply with exactly the word WF_CHILD_OK and nothing else.' + +describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let prompt: string + + const waitForParentSettlement = (): Promise => new Promise((resolve, reject) => { + let dispose = (): void => {} + dispose = scaffold.ctx.on('session/event', (session: Session, event: SessionEvent) => { + if (event.type !== 'turn/end' || session.header.origin === 'subagent') return + dispose() + void (async () => { + await scaffold.ctx.agents.get(session.id)?.whenIdle() + await scaffold.ctx.sessions.flush(session) + resolve(session.id) + })().catch(reject) + }) + }) + + beforeAll(async () => { + const prompts = fixtureUserPrompts(await readFile(PARENT_FIXTURE, 'utf8')) + expect(prompts).toHaveLength(1) + prompt = prompts[0]! + scaffold = await launchWebScaffold({ + replayFixture: PARENT_FIXTURE, + replayChildFixtures: [CHILD_FIXTURE], + paceMs: 25, + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live')) + const settled = waitForParentSettlement() + const input = page.locator('textarea').first() + await input.fill(prompt) + await input.press('Enter') + + const workflow = page.getByRole('button', { name: /^snapshot-flow/ }) + await workflow.waitFor({ timeout: 30_000 }) + expect(await workflow.getAttribute('aria-expanded')).toBe('true') + const phase = page.getByRole('button', { name: /^Run/ }) + await phase.waitFor({ timeout: 15_000 }) + await phase.click() + const member = page.getByRole('button', { name: /^Open Reply with exactly the word/ }) + await member.waitFor({ timeout: 15_000 }) + await member.focus() + + const lightColor = await member.locator('[data-member-label]').evaluate(element => getComputedStyle(element).color) + await page.setViewportSize({ width: 560, height: 800 }) + await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) + const darkNarrow = await page.locator('[data-workflow-run]').evaluate((element) => { + const panel = element as HTMLElement + panel.style.width = '356px' + const label = element.querySelector('[data-member-label]') + const labelWrap = element.querySelector('[data-member-label-wrap]') + const status = element.querySelector('[data-member-status-text]') + const runHeader = element.querySelector('[data-run-header]') + const phaseHeader = element.querySelector('[data-phase-header]') + return { + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + color: label === null ? '' : getComputedStyle(label).color, + decoration: label === null ? '' : getComputedStyle(label).textDecorationLine, + focusWidth: labelWrap === null ? '' : getComputedStyle(labelWrap).outlineWidth, + statusWidth: status?.getBoundingClientRect().width ?? 0, + statusFontSize: status === null ? '' : getComputedStyle(status).fontSize, + runHeight: runHeader?.getBoundingClientRect().height ?? 0, + phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0, + } + }) + expect(darkNarrow.clientWidth).toBe(356) + expect(darkNarrow.scrollWidth).toBeLessThanOrEqual(darkNarrow.clientWidth) + expect(darkNarrow.color).not.toBe(lightColor) + expect(darkNarrow.decoration).toContain('underline') + expect(Number.parseFloat(darkNarrow.focusWidth)).toBeGreaterThanOrEqual(2) + expect(darkNarrow.statusWidth).toBe(64) + expect(darkNarrow.statusFontSize).toBe('13px') + expect(darkNarrow.runHeight).toBe(32) + expect(darkNarrow.phaseHeight).toBe(32) + await page.locator('[data-workflow-run]').evaluate((element) => { + (element as HTMLElement).style.removeProperty('width') + document.body.removeAttribute('data-ds-dark-theme') + }) + await page.setViewportSize({ width: 1280, height: 800 }) + + await member.click() + await page.getByText(CHILD_PROMPT, { exact: true }).waitFor({ timeout: 15_000 }) + + const sessions = page.getByRole('tree', { name: 'Sessions' }) + await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click() + await settled + + expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1) + expect(await page.locator('[data-chat-flow-kind="workflow-run"]').count()).toBe(1) + const terminalWorkflow = page.getByRole('button', { name: /^snapshot-flow/ }) + await terminalWorkflow.waitFor() + if (await terminalWorkflow.getAttribute('aria-expanded') !== 'true') await terminalWorkflow.click() + const terminalPhase = page.getByRole('button', { name: /^Run/ }) + await terminalPhase.waitFor() + if (await terminalPhase.getAttribute('aria-expanded') !== 'true') await terminalPhase.click() + await page.getByText(CHILD_PROMPT, { exact: false }).waitFor() + await expect.poll( + () => page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count(), + { timeout: 10_000 }, + ).toBe(0) + }, 90_000) + + it('rebuilds the terminal record from history after reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history')) + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const workflow = page.getByRole('button', { name: /^snapshot-flow/ }) + await workflow.waitFor({ timeout: 15_000 }) + expect(await workflow.getAttribute('aria-expanded')).toBe('false') + await workflow.click() + const phase = page.getByRole('button', { name: /^Run/ }) + await phase.waitFor() + await phase.click() + await page.getByText(CHILD_PROMPT, { exact: false }).waitFor() + expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }, 60_000) + + it('stays clean and owns only its one golden', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index da25f0cc59..9fe5a0575d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -82,7 +82,8 @@ "tests/chat-continuous-conversation.e2e.ts", "tests/composer-tab-geometry.e2e.ts", "tests/complex-history.perf.ts", - "tests/pwsh-terminal.e2e.ts" + "tests/pwsh-terminal.e2e.ts", + "tests/workflow-run.e2e.ts" ], "references": [ { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09c961ef69..9a0d47093e 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 51c6ae46eeca1279390c9d9315a6161edd2de618 -config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df +config-catalog.md: d9b70eb15865b45d0d8251789d6d661cd9747024 +config-catalog.zh.md: 4974a7e53c60507c2dced9a93cb5e2a2ba0ed850 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51c6ae46ee..d9b70eb158 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2412,7 +2412,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:27`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` @@ -2725,6 +2725,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-tool` ([`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-workflow-run` ([`packages/client/ui-workflow-run/src/index.ts`](../packages/client/ui-workflow-run/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) - `@deepseek-ai/dsh-command-compact` — requires `commands` · `compact` ([`packages/compact/command-compact/src/index.ts`](../packages/compact/command-compact/src/index.ts)) - `@deepseek-ai/dsh-command-feedback` — requires `commands` ([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dc93f5b4b5..4974a7e53c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2413,7 +2413,7 @@ export interface Config { } ``` -来源:[`packages/workflow/tool-workflow/src/index.ts:27`](../packages/workflow/tool-workflow/src/index.ts) +来源:[`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` @@ -2726,6 +2726,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-tool`([`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory`([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-workflow-run`([`packages/client/ui-workflow-run/src/index.ts`](../packages/client/ui-workflow-run/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace`([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) - `@deepseek-ai/dsh-command-compact` — 需要 `commands` · `compact`([`packages/compact/command-compact/src/index.ts`](../packages/compact/command-compact/src/index.ts)) - `@deepseek-ai/dsh-command-feedback` — 需要 `commands`([`packages/feedback/command-feedback/src/index.ts`](../packages/feedback/command-feedback/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index a2caf7b784..16178a7986 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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 docs/event-producer-consumer.md -event-producer-consumer.md: b78171ce51931f02a3f39ef98104ea9dedc27360 -event-producer-consumer.zh.md: c044385bf91559f5c4f82d99601642b932066e7f +event-producer-consumer.md: 0963d996b50a363d434ede40a877cb89d8ba9923 +event-producer-consumer.zh.md: d258f775e8724d18f64ea4ed77c39ae74a73f45c diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b78171ce51..0963d996b5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,9 +30,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | @@ -50,12 +50,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../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) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../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:183`](../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) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | +| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source @@ -64,7 +64,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | -| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index c044385bf9..d258f775e8 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,9 +32,9 @@ | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | @@ -51,13 +51,13 @@ | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`timeout-policy`](../packages/guard/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:161`](../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) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../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:183`](../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) | -| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:60`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:53`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:45`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | +| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:182`](../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:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | +| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | +| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | +| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | ## Non-harness or undeclared event strings seen in package source @@ -66,7 +66,7 @@ | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` | | `credentials/changed` | `runtime` (`emit`) | `ui-models` | -| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `loader`, [`lsp-local`](../packages/lsp/lsp-local), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets)、`gateway` | | `internal/status` | - | [`agent`](../packages/core/agent) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d478f79cd1..bb2181029d 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 6763ca6e84a5cc2e5e776a56e5cfc20b710017aa -module-graph.zh.md: 339345b39a8225c34ecf0ba73efff34d4b940046 +module-graph.md: bf1b3cb36de9071ca1d4d6d0b8795a28435916c6 +module-graph.zh.md: 3deb62f24c0c319922f80d33f5a7d726bf0a2d9f diff --git a/docs/module-graph.md b/docs/module-graph.md index 6763ca6e84..bf1b3cb36d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -169,6 +169,7 @@ flowchart TD pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_tool["client-ui-tool"] pkg_client_ui_trajectory["client-ui-trajectory"] + pkg_client_ui_workflow_run["client-ui-workflow-run"] pkg_client_ui_workspace["client-ui-workspace"] pkg_client_web["client-web"] pkg_client_web_react["client-web-react"] @@ -969,6 +970,7 @@ flowchart TD pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm + pkg_tool_workflow --> pkg_session pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow @@ -1160,6 +1162,15 @@ flowchart TD pkg_client_ui_tool --> pkg_client_ui_primitives pkg_client_ui_tool --> pkg_client_ui_slots pkg_client_ui_tool --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_client_locale + pkg_client_ui_workflow_run --> pkg_client_runtime + pkg_client_ui_workflow_run --> pkg_client_ui_conversation + pkg_client_ui_workflow_run --> pkg_client_ui_primitives + pkg_client_ui_workflow_run --> pkg_client_ui_slots + pkg_client_ui_workflow_run --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_session + pkg_client_ui_workflow_run --> pkg_tool_workflow + pkg_client_ui_workflow_run --> pkg_workflow pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1412,7 +1423,7 @@ flowchart TD | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | @@ -1440,6 +1451,7 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 339345b39a..3deb62f24c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -171,6 +171,7 @@ flowchart TD pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_tool["client-ui-tool"] pkg_client_ui_trajectory["client-ui-trajectory"] + pkg_client_ui_workflow_run["client-ui-workflow-run"] pkg_client_ui_workspace["client-ui-workspace"] pkg_client_web["client-web"] pkg_client_web_react["client-web-react"] @@ -971,6 +972,7 @@ flowchart TD pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_invariants pkg_tool_workflow --> pkg_llm + pkg_tool_workflow --> pkg_session pkg_tool_workflow --> pkg_system_prompt pkg_tool_workflow --> pkg_tools pkg_tool_workflow --> pkg_workflow @@ -1162,6 +1164,15 @@ flowchart TD pkg_client_ui_tool --> pkg_client_ui_primitives pkg_client_ui_tool --> pkg_client_ui_slots pkg_client_ui_tool --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_client_locale + pkg_client_ui_workflow_run --> pkg_client_runtime + pkg_client_ui_workflow_run --> pkg_client_ui_conversation + pkg_client_ui_workflow_run --> pkg_client_ui_primitives + pkg_client_ui_workflow_run --> pkg_client_ui_slots + pkg_client_ui_workflow_run --> pkg_invariants + pkg_client_ui_workflow_run --> pkg_session + pkg_client_ui_workflow_run --> pkg_tool_workflow + pkg_client_ui_workflow_run --> pkg_workflow pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_bash_env @@ -1414,7 +1425,7 @@ flowchart TD | [`session-title-all-messages-llm`](../packages/session/session-title-all-messages-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-message-llm`](../packages/session/session-title-first-message-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | -| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | +| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/bash/tool-pwsh) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | @@ -1442,6 +1453,7 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`sdk-client`](../packages/scaffold/client) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ee0b5cbdd6..a8b6842eb6 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.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 docs/persistence-catalog.md -persistence-catalog.md: f44569d3bacec0a832f4b4bca6acf4abb0846a0d -persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 +persistence-catalog.md: 34803a69f11a964ccc6da3e74eb678fe398f94c8 +persistence-catalog.zh.md: 3146712e342519fdf687d5b18bd1c95c8c0f0a37 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f44569d3ba..34803a69f1 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -716,6 +716,56 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +### `tool-workflow/*` + +#### `tool-workflow/agent-end` — log-only + +```ts persistence-catalog +/** + * Records one member settlement. + * @param data - run identity, paired member sequence, and outcome. + */ +'tool-workflow/agent-end': ToolWorkflowAgentEndData +``` + +Source: [`packages/workflow/tool-workflow/src/types.ts:57`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/agent-start` — log-only + +```ts persistence-catalog +/** + * Records one published workflow member. + * @param data - run identity, member sequence, display identity, and child Session. + */ +'tool-workflow/agent-start': ToolWorkflowAgentStartData +``` + +Source: [`packages/workflow/tool-workflow/src/types.ts:52`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/run-end` — log-only + +```ts persistence-catalog +/** + * Closes one workflow record after cleanup. + * @param data - stable run identity and terminal reason. + */ +'tool-workflow/run-end': ToolWorkflowRunEndData +``` + +Source: [`packages/workflow/tool-workflow/src/types.ts:62`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/run-start` — log-only + +```ts persistence-catalog +/** + * Opens one top-level workflow record. + * @param data - stable run identity and display name. + */ +'tool-workflow/run-start': ToolWorkflowRunStartData +``` + +Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow/tool-workflow/src/types.ts) + ### `turn/*` #### `turn/end` — log-only diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 21ed29a3da..3146712e34 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -718,6 +718,56 @@ export type SessionEvent = { 来源:[`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +### `tool-workflow/*` + +#### `tool-workflow/agent-end` — log-only + +```ts persistence-catalog +/** + * Records one member settlement. + * @param data - run identity, paired member sequence, and outcome. + */ +'tool-workflow/agent-end': ToolWorkflowAgentEndData +``` + +来源:[`packages/workflow/tool-workflow/src/types.ts:57`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/agent-start` — log-only + +```ts persistence-catalog +/** + * Records one published workflow member. + * @param data - run identity, member sequence, display identity, and child Session. + */ +'tool-workflow/agent-start': ToolWorkflowAgentStartData +``` + +来源:[`packages/workflow/tool-workflow/src/types.ts:52`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/run-end` — log-only + +```ts persistence-catalog +/** + * Closes one workflow record after cleanup. + * @param data - stable run identity and terminal reason. + */ +'tool-workflow/run-end': ToolWorkflowRunEndData +``` + +来源:[`packages/workflow/tool-workflow/src/types.ts:62`](../packages/workflow/tool-workflow/src/types.ts) + +#### `tool-workflow/run-start` — log-only + +```ts persistence-catalog +/** + * Opens one top-level workflow record. + * @param data - stable run identity and display name. + */ +'tool-workflow/run-start': ToolWorkflowRunStartData +``` + +来源:[`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow/tool-workflow/src/types.ts) + ### `turn/*` #### `turn/end` — log-only diff --git a/docs/subsystems/workflow.i18n.yaml b/docs/subsystems/workflow.i18n.yaml index b18eeced08..3100aaeddc 100644 --- a/docs/subsystems/workflow.i18n.yaml +++ b/docs/subsystems/workflow.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 docs/subsystems/workflow.md -workflow.md: 22dcaad608cc2ca7f407b8837fc3856abcc43555 -workflow.zh.md: 7ccd47f414ad574f2daa8e74f6cfb65abfbe06c2 +workflow.md: b651a5459d4ff8c71de223ca2b51dca997ab86bf +workflow.zh.md: 0fd32675c8612dfeee1dbce7cd8e9977bbe330ef diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index 22dcaad608..b651a5459d 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -6,7 +6,7 @@ The workflow seam lets an agent run a model-written orchestration SCRIPT that st Service Definition: [dsh-workflow](../../packages/workflow/workflow) (`ctx.workflows` + the vocabulary below). The Service provider is [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread) (a `node:worker_threads` engine — one worker per run, the script's vm context inside it); the model-facing Consumer is [dsh-tool-workflow](../../packages/workflow/tool-workflow). The proposal and rationale: [the dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md). -Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) +Sources: browser-safe vocabulary in [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts), Host request and live-run handles in [`runtime-types.ts`](../../packages/workflow/workflow/src/runtime-types.ts). ## The start request @@ -15,33 +15,23 @@ What a caller asks for when starting a run. The ordinary workflow tool builds th ```ts type-equiv /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; - * the engine validates `meta` against its schema and rejects loud - * before anything runs) — an engine never evaluates script text to obtain - * them. `parent` is REQUIRED — every `agent()` the script spawns is - * attributed to it (cwd, lineage, depth flow through the subagent seam). + * plain JSON data by the seam contract. `parent` is required because every + * `agent()` spawned by the script is attributed to that live Agent. */ interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return `). */ script: string - /** The workflow's identity fields as plain JSON data, validated by the engine. */ + /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown - /** - * Optional engine-wide child-provider override for this run. The workflow - * script cannot observe or replace it; omission uses the engine's configured - * provider. - */ + /** Optional engine-wide child-provider override for this run. */ subagentProvider?: string - /** - * Optional per-run total-child ceiling. Implementations reject values above - * their deployment ceiling before publishing the run. - */ + /** Optional per-run total-child ceiling. */ maxTotalAgents?: number /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent - /** Cancels the run when aborted (the tool's `exec.signal`). */ + /** Cancels the run when aborted. */ signal?: AbortSignal } ``` @@ -76,7 +66,7 @@ The outcome of one run, resolved by `WorkflowRun.result`. `value` is the script' ```ts type-equiv /** - * The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is + * The outcome resolved by a live workflow run. `value` is * the script's materialized return value (plain host-realm JSON data; `null` * when the script returned `undefined`) — meaningful only for `completed`. * A non-`completed` reason carries the failure in `error`; the consumer maps @@ -106,19 +96,17 @@ The handle the consumer holds while a script executes. The consumer awaits `resu ```ts type-equiv /** - * Holder-owned live workflow. `result` never rejects and settles within the - * engine's cancellation grace; failures resolve through `stopReason`. Consumers - * may cancel and must call idempotent `dispose()` on every path to await bounded - * script settlement and child quiescence. + * Holder-owned live workflow. `result` never rejects; consumers may cancel + * and must call idempotent `dispose()` to await script and child quiescence. */ interface WorkflowRun { readonly id: WorkflowRunId - /** The validated meta block (available before the body runs). */ + /** The validated meta block available before the script body runs. */ readonly meta: WorkflowMeta readonly result: Promise - /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */ + /** Cancel the run and its children. */ cancel(reason?: string): void - /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ + /** Cancel if needed and await bounded settlement and cleanup. */ dispose(): Promise } ``` @@ -131,6 +119,14 @@ Hook misuse inside a script — bad arguments, unknown/deferred `agent()` option The `workflow/*` events (`workflow/start`, `workflow/phase`, `workflow/log`, `workflow/agent-start`, `workflow/agent-end`, `workflow/end` — see the [events catalog](#cordis-surface)) are **observe-only** emits carrying DATA SNAPSHOTS: every payload starts with `WorkflowRunInfo` (id + meta), never the live `WorkflowRun`, so a subscriber cannot gain `cancel`/`dispose`, and `workflow/end` deliberately omits the result value (a listener observing outcomes must not receive a mutable alias of the caller's result). Every emit is per-listener contained — a throwing subscriber is logged, never propagated, and cannot starve the listeners registered after it — and every listener receives its own payload clone, so mutating it corrupts neither the engine nor other listeners; the containment mirrors `subagent/start`/`subagent/end`. +## Durable Chat records + +The top-level `dsh-tool-workflow` consumer projects display facts into its calling parent Session without changing execution ownership. It writes `tool-workflow/run-start` after a run is accepted, pairs member start and end by `runId + seq`, and writes `tool-workflow/run-end` only after the result is known and disposal reaches quiescence. Nested transport calls write no record. The first append failure disables later writes for that run, so the log remains empty or a legal continuous prefix and the tool result is unchanged. + +`dsh-tool-workflow/invariant` validates the same protocol before live commit and when a Session is loaded: one start per run, positive unique member sequences, paired member endings, no run ending with open members, and no updates after the run ending. A missing member ending or run ending at the log tail is valid interruption evidence rather than corruption. + +`dsh-client-ui-workflow-run` folds the four events through the Conversation Node engine into one `workflow-run` Chat node anchored at the run-start sequence, after the original workflow tool node. Phase groups come only from actual member starts and preserve exact strings, including the distinction between an omitted phase and `''`. Closed Locations turn missing terminal facts into interrupted presentation. The 32-pixel run row uses module-platform background, persistent chevrons, and inline dot plus status text; 32-pixel phase rows keep title and count in the main area and precise aggregate status in a fixed tail without another dot; members use a 16-pixel dot slot and fixed 64-pixel lifecycle column. Underlined names alone mark navigation while the member and current list both prove a running same-parent local subagent. + @@ -155,7 +151,7 @@ Workflow Service Definition contract. Invalid requests throw before publication; abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:157`](../../packages/workflow/workflow/src/index.ts) @@ -181,7 +177,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:79`](../../packages/workflow/workflow/src/index.ts) @@ -202,7 +198,7 @@ One `agent()` call established a published child run. Paired with Events['workfl 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts) @@ -223,7 +219,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:89`](../../packages/workflow/workflow/src/index.ts) @@ -241,7 +237,7 @@ The script emitted a narration line (a `log(message)` call). 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:58`](../../packages/workflow/workflow/src/index.ts) @@ -260,7 +256,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:51`](../../packages/workflow/workflow/src/index.ts) @@ -278,5 +274,5 @@ A workflow run started — the script's meta block validated, the body about to 'workflow/start'(info: WorkflowRunInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:43`](../../packages/workflow/workflow/src/index.ts) diff --git a/docs/subsystems/workflow.zh.md b/docs/subsystems/workflow.zh.md index 7ccd47f414..0fd32675c8 100644 --- a/docs/subsystems/workflow.zh.md +++ b/docs/subsystems/workflow.zh.md @@ -6,7 +6,7 @@ Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.workflows` + 下文词汇)。Service provider 是 [dsh-workflow-workerthread](../../packages/workflow/workflow-workerthread)(一个 `node:worker_threads` 引擎——每个 run 一个 worker,脚本的 vm 上下文位于其中);面向模型的 Consumer 是 [dsh-tool-workflow](../../packages/workflow/tool-workflow)。提案与设计理由见 [dynamic-workflows Agent Note](../../.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.md)。 -源码:[`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts) +源码:浏览器安全词汇位于 [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/workflow/src/types.ts),Host 请求与活跃运行句柄位于 [`runtime-types.ts`](../../packages/workflow/workflow/src/runtime-types.ts)。 ## 启动请求 @@ -15,33 +15,23 @@ Service Definition:[dsh-workflow](../../packages/workflow/workflow)(`ctx.wor ```ts type-equiv /** * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; - * the engine validates `meta` against its schema and rejects loud - * before anything runs) — an engine never evaluates script text to obtain - * them. `parent` is REQUIRED — every `agent()` the script spawns is - * attributed to it (cwd, lineage, depth flow through the subagent seam). + * plain JSON data by the seam contract. `parent` is required because every + * `agent()` spawned by the script is attributed to that live Agent. */ interface WorkflowStartRequest { /** The plain-JS script body (top-level await allowed; ends with `return `). */ script: string - /** The workflow's identity fields as plain JSON data, validated by the engine. */ + /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ meta: WorkflowMeta /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown - /** - * Optional engine-wide child-provider override for this run. The workflow - * script cannot observe or replace it; omission uses the engine's configured - * provider. - */ + /** Optional engine-wide child-provider override for this run. */ subagentProvider?: string - /** - * Optional per-run total-child ceiling. Implementations reject values above - * their deployment ceiling before publishing the run. - */ + /** Optional per-run total-child ceiling. */ maxTotalAgents?: number /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent - /** Cancels the run when aborted (the tool's `exec.signal`). */ + /** Cancels the run when aborted. */ signal?: AbortSignal } ``` @@ -76,7 +66,7 @@ interface WorkflowMeta { ```ts type-equiv /** - * The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is + * The outcome resolved by a live workflow run. `value` is * the script's materialized return value (plain host-realm JSON data; `null` * when the script returned `undefined`) — meaningful only for `completed`. * A non-`completed` reason carries the failure in `error`; the consumer maps @@ -106,19 +96,17 @@ interface WorkflowResult { ```ts type-equiv /** - * Holder-owned live workflow. `result` never rejects and settles within the - * engine's cancellation grace; failures resolve through `stopReason`. Consumers - * may cancel and must call idempotent `dispose()` on every path to await bounded - * script settlement and child quiescence. + * Holder-owned live workflow. `result` never rejects; consumers may cancel + * and must call idempotent `dispose()` to await script and child quiescence. */ interface WorkflowRun { readonly id: WorkflowRunId - /** The validated meta block (available before the body runs). */ + /** The validated meta block available before the script body runs. */ readonly meta: WorkflowMeta readonly result: Promise - /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */ + /** Cancel the run and its children. */ cancel(reason?: string): void - /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ + /** Cancel if needed and await bounded settlement and cleanup. */ dispose(): Promise } ``` @@ -131,6 +119,14 @@ interface WorkflowRun { `workflow/*` 事件(`workflow/start`、`workflow/phase`、`workflow/log`、`workflow/agent-start`、`workflow/agent-end`、`workflow/end`,见[事件目录](#cordis-surface))是**仅供观察**的 emit,携带数据快照:每个 payload 以 `WorkflowRunInfo`(id + meta)开头,而非活跃的 `WorkflowRun`,因此订阅者无法获得 `cancel`/`dispose`;`workflow/end` 刻意省略 result value(观察结果的监听器不得收到调用方 result 的可变别名)。每次 emit 对每个监听器隔离:抛出异常的订阅者被记录日志但不传播,不会饿死在它之后注册的监听器;每个监听器收到自己的 payload 克隆,因此修改它既不会损坏引擎也不会影响其他监听器。这种隔离方式与 `subagent/start`/`subagent/end` 一致。 +## 持久 Chat 记录 + +顶层 `dsh-tool-workflow` 消费方把展示事实投影到调用它的父 Session,同时不改变执行所有权。运行接受后写 `tool-workflow/run-start`,以 `runId + seq` 配对成员开始与结束,并且只在结果已取得且 dispose 完全停稳后写 `tool-workflow/run-end`。嵌套 transport 调用不写记录。第一次 append 失败会禁用本运行后续写入,因此日志保持为空或合法连续前缀,工具结果不变。 + +`dsh-tool-workflow/invariant` 会在实时提交前和 Session 加载时校验同一协议:每个运行只有一个 start,成员序号为正且唯一,成员 end 必须配对,仍有开放成员时不能结束运行,运行结束后不能继续更新。日志尾部缺少成员 end 或 run end 是有效的中断证据,不是损坏。 + +`dsh-client-ui-workflow-run` 通过 Conversation Node 引擎把四类事件折叠为一个 `workflow-run` Chat 节点,以 run-start 序号锚定在原工作流工具节点之后。阶段组只来自真正开始过的成员,并保留精确字符串,包括字段缺省与 `''` 的区别。Location 关闭时,缺失终点会显示为已中断。32 像素运行行使用 module-platform 背景、常驻 chevron 与内联状态点加文字;32 像素阶段行在主区显示标题和计数,在固定尾部精确显示聚合状态且不重复状态点;成员使用 16 像素状态点槽和固定 64 像素生命周期列。只有成员状态与当前列表同时证明它是同父级、仍运行的本地 subagent 时,带下划线名称才标记普通 Session 导航。 + @@ -155,7 +151,7 @@ Workflow Service Definition contract. Invalid requests throw before publication; abstract start(request: WorkflowStartRequest): WorkflowRun ``` -Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:157`](../../packages/workflow/workflow/src/index.ts) @@ -181,7 +177,7 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:79`](../../packages/workflow/workflow/src/index.ts) @@ -202,7 +198,7 @@ One `agent()` call established a published child run. Paired with Events['workfl 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:68`](../../packages/workflow/workflow/src/index.ts) @@ -223,7 +219,7 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:89`](../../packages/workflow/workflow/src/index.ts) @@ -241,7 +237,7 @@ The script emitted a narration line (a `log(message)` call). 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:58`](../../packages/workflow/workflow/src/index.ts) @@ -260,7 +256,7 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` -Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:51`](../../packages/workflow/workflow/src/index.ts) @@ -278,5 +274,5 @@ A workflow run started — the script's meta block validated, the body about to 'workflow/start'(info: WorkflowRunInfo): void ``` -Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/workflow/src/index.ts) +Source: [`packages/workflow/workflow/src/index.ts:43`](../../packages/workflow/workflow/src/index.ts) diff --git a/knip.json b/knip.json index 249d21d857..e8dac8a2f1 100644 --- a/knip.json +++ b/knip.json @@ -156,6 +156,16 @@ "tests/**/*.tsx" ] }, + "packages/client/ui-workflow-run": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, "packages/client/web-react": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 216eb13199..dc399b9736 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -149,6 +149,11 @@ - id: ui-tool name: '@deepseek-ai/dsh-client-ui-tool' + # Durable workflow lifecycle as an independent Chat node after the + # existing generic workflow tool row. + - id: ui-workflow-run + name: '@deepseek-ai/dsh-client-ui-workflow-run' + # Turn tail: the produced-files row under each closing assistant message. # Remove this entry to turn the surface off; the tail hole renders empty. - id: ui-deliverables diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 4f8b8d4318..7d665f4893 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", + "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 816f8737e7..d85bc38fd2 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.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 packages/client/README.md -README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd -README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169 +README.md: 6b3904c1e97a5a3da4864731aa624b3afbf5d027 +README.zh.md: 9f71ffa04fb80f0fd6d62b1d4b23f0ea1474c107 diff --git a/packages/client/README.md b/packages/client/README.md index 567e10f74a..6b3904c1e9 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -23,6 +23,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. | | [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. | | [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. | +| [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested Chat disclosures with live-only child navigation. | | [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. | | [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. | | [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index ad6a9fb199..9f71ffa04f 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -23,6 +23,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 | | [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 | | [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 | +| [`ui-workflow-run/`](ui-workflow-run/README.md) | 把持久工作流运行回放为 Chat 嵌套折叠项,并只为实时子 Session 提供导航。 | | [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 | | [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 | | [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 | diff --git a/packages/client/ui-workflow-run/README.i18n.yaml b/packages/client/ui-workflow-run/README.i18n.yaml new file mode 100644 index 0000000000..3d6294e997 --- /dev/null +++ b/packages/client/ui-workflow-run/README.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 packages/client/ui-workflow-run/README.md +README.md: 66539e0c16ac4102f9e1fe881106e6881b36a7d5 +README.zh.md: a803857af24802e8a4645c4d5aca56c04424c85e diff --git a/packages/client/ui-workflow-run/README.md b/packages/client/ui-workflow-run/README.md new file mode 100644 index 0000000000..66539e0c16 --- /dev/null +++ b/packages/client/ui-workflow-run/README.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-client-ui-workflow-run + +English | [中文](README.zh.md) + +The browser plugin that reconstructs durable top-level workflow runs as independent Chat nodes. It consumes the four `tool-workflow/*` Session events owned by [`dsh-tool-workflow`](../../workflow/tool-workflow/README.md), registers one `ConversationNodeDefinition`, and renders through the keyed `conversation.chat.node` slot without changing the existing workflow tool card. + +## Durable state and replay + +`tool-workflow/run-start` creates one Context keyed by `runId`; member starts, member endings, and the run ending update that Context in log order. A history tail containing only updates remains pending until an older page supplies the unique start, after which prepend, complete replay, and live append produce the same state. A closed Turn or Step with missing terminal events presents the affected run or members as interrupted without changing the tool result. + +Phase groups come only from members that actually started. Exact phase strings share a group, an omitted phase is distinct from the empty string, and settlement changes status without removing or reordering members. + +## Presentation and navigation + +The run and each phase have independent disclosure state. The run uses a 32-pixel `--dsw-alias-bg-module-platform` row with persistent right/down chevrons and an inline state dot plus status text, without a badge. Phases use 32-pixel disclosure rows with title and member count in the flexible main area and a fixed precise aggregate-status tail, without another dot. Members use a 16-pixel dot slot, a truncating name area, and a fixed 64-pixel status column. A running run initially expands; a terminal run loaded from history initially collapses. Local choices survive data updates while the keyed node remains mounted and reset only on a full remount. + +A member opens a child Session only while every current fact agrees: the member is running, the child id is in the ordinary Session list, the row has `origin: 'subagent'`, its `parentId` is the current Session, and the list row is still running. Underlined member text is the only visible navigation affordance; keyboard focus draws a two-pixel business-primary ring around the name area, while status copy remains `Running`. The component calls only the injected ordinary `sessions.open(id)` action; remote, addressed-only, wrong-parent, or terminal rows remain non-interactive. + +## Composition + +The package registers its Definition, locale dictionary, and `workflow-run` renderer as Cordis effects. Removing the client entry retracts all three contributions. The shipped Web bundle includes the plugin after `ui-conversation` and `ui-tool`. + +## Model Experience + +None, as this package renders durable Session facts for humans and adds no prompt, tool schema, request content, or model-visible result. + +#### KV Cache effect + +None. + +## Known Limitations and Deferred Work + +- Only top-level calls through `dsh-tool-workflow` produce these records; nested Code Mode calls and direct `WorkflowService` consumers do not. +- Navigation is intentionally live-only. Terminal members remain visible for review but never expose a cold-session opener from this node. +- The node shows run, phase, member identity, and status only; scripts, outputs, errors, logs, usage, static topology, and controls remain outside this surface. diff --git a/packages/client/ui-workflow-run/README.zh.md b/packages/client/ui-workflow-run/README.zh.md new file mode 100644 index 0000000000..a803857af2 --- /dev/null +++ b/packages/client/ui-workflow-run/README.zh.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-client-ui-workflow-run + +[English](README.md) | 中文 + +这个浏览器插件把持久化的顶层工作流运行重建为独立 Chat 节点。它消费由 [`dsh-tool-workflow`](../../workflow/tool-workflow/README.md) 拥有的四类 `tool-workflow/*` Session 事件,注册一个 `ConversationNodeDefinition`,并通过 keyed `conversation.chat.node` slot 渲染,不改变现有工作流工具卡。 + +## 持久状态与回放 + +`tool-workflow/run-start` 以 `runId` 创建唯一 Context;成员开始、成员结束和运行结束事件按日志顺序更新该 Context。只有 update 的历史尾页会保持 pending,直到更早页面补入唯一 start;此后 prepend、完整回放和实时 append 得到相同状态。若所属 Turn 或 Step 已关闭但终点事件缺失,界面把相应运行或成员显示为已中断,而不改写工具结果。 + +阶段组只来自真正开始过的成员。完全相同的阶段字符串归入同一组,字段缺省与空字符串保持不同身份;成员结算只改变状态,不删除或重排成员。 + +## 展示与导航 + +运行和每个阶段分别拥有本地 disclosure 状态。运行使用 32 像素 `--dsw-alias-bg-module-platform` 背景行,常驻向右/向下 chevron,并以内联状态点加状态文字表达结局,不使用胶囊。阶段使用 32 像素 disclosure 行,在可伸缩主区显示标题与成员数,在固定尾部精确显示聚合状态且不重复状态点。成员使用 16 像素状态点槽、可省略名称区和固定 64 像素状态列。运行中记录首次挂载时展开,从历史加载的终态记录首次挂载时折叠。只要 keyed 节点仍挂载,本地选择就在数据更新时保持;只有完整 remount 才重新初始化。 + +只有所有实时事实同时成立时,成员才可打开子 Session:成员仍在运行、子 id 位于普通 Session 列表、列表行为 `origin: 'subagent'`、`parentId` 等于当前 Session,且列表行仍标记运行。带下划线的成员文字是唯一可见导航提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,右侧状态仍只显示“运行中”。组件只调用注入的普通 `sessions.open(id)`;远程、仅地址化、父级不符或终态的行都不可交互。 + +## 装配 + +本包把 Definition、locale 字典和 `workflow-run` renderer 都注册为 Cordis effect;移除客户端 entry 会撤销三者。shipped Web bundle 在 `ui-conversation` 与 `ui-tool` 之后装配该插件。 + +## Model Experience + +无,因为本包只为人类展示持久 Session 事实,不增加 prompt、工具 schema、请求内容或模型可见结果。 + +#### KV Cache effect + +无。 + +## Known Limitations and Deferred Work + +- 只有经 `dsh-tool-workflow` 发起的顶层调用会生成这些记录;嵌套 Code Mode 调用和直接 `WorkflowService` 消费方不会生成。 +- 导航刻意只面向实时运行。终态成员继续保留供复盘,但本节点永不为其提供冷 Session 入口。 +- 节点只显示运行、阶段、成员身份与状态;脚本、输出、错误、日志、用量、静态拓扑和控制操作都不属于本界面。 diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json new file mode 100644 index 0000000000..149bd99906 --- /dev/null +++ b/packages/client/ui-workflow-run/package.json @@ -0,0 +1,73 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-workflow-run", + "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "dependencies": { + "react": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-tool-workflow": "^0.0.1", + "@deepseek-ai/dsh-workflow": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css new file mode 100644 index 0000000000..0f069ac77b --- /dev/null +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -0,0 +1,250 @@ +.root { + width: 100%; + min-width: 0; +} + +.runHeader { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + height: 32px; + padding: 0 8px; + border-radius: 8px; + background: var(--dsw-alias-bg-module-platform); + cursor: pointer; +} + +.runHeader:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; +} + +.runLeading { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + color: var(--dsw-alias-label-tertiary); +} + +.runTitle { + overflow: hidden; + flex: none; + max-width: 42%; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + font-weight: 510; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.runSummary { + overflow: hidden; + flex: 1; + min-width: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.statusTail { + display: inline-flex; + flex: none; + height: 20px; + align-items: center; + gap: 4px; + overflow: hidden; + font-size: 11px; + font-weight: 510; + line-height: 16px; + color: var(--dsw-alias-label-secondary); + white-space: nowrap; +} + +.phaseHeader { + box-sizing: border-box; + display: flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + height: 32px; + cursor: pointer; +} + +.phaseHeader:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; + border-radius: 4px; +} + +.phaseLeading { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + color: var(--dsw-alias-label-tertiary); +} + +.phaseTitle { + flex: none; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; + white-space: nowrap; +} + +.phaseCount { + overflow: hidden; + flex: 1; + min-width: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.phaseStatus { + overflow: hidden; + flex: none; + width: 132px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.separator { + flex: none; + width: 2px; + height: 2px; + border-radius: 50%; + background: var(--dsw-alias-label-tertiary); +} + +.phaseList { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 4px 0 0 16px; +} + +.phase { + min-width: 0; +} + +.members { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + padding: 0 0 0 16px; +} + +.memberRow, +.memberButton { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-width: 0; + min-height: 24px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: inherit; + text-align: left; +} + +.memberButton { + cursor: pointer; +} + +.memberButton .memberLabel { + color: var(--dsw-alias-state-business-primary); + text-decoration: underline; + text-underline-position: from-font; +} + +.dotSlot { + display: inline-flex; + flex: none; + width: 16px; + height: 24px; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.memberButton:focus-visible { + outline: none; +} + +.memberButton:focus-visible .memberLabelWrap { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -1px; +} + +.memberLabelWrap { + display: flex; + overflow: hidden; + flex: 1; + min-width: 0; + height: 24px; + align-items: center; + padding: 0 2px; + border-radius: 4px; +} + +.memberLabel { + overflow: hidden; + flex: 1; + min-width: 0; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.memberStatus { + flex: none; + overflow: hidden; + width: 64px; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty { + color: var(--dsw-alias-label-tertiary); + font-size: 13px; + line-height: 20px; + padding: 0; +} + +@media (max-width: 560px) { + .phaseList, + .members { + padding-left: 12px; + } +} diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx new file mode 100644 index 0000000000..313bb06c97 --- /dev/null +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -0,0 +1,235 @@ +import { useMemo, useState, type KeyboardEvent } from 'react' +import { + IconChevronDownOutline14, IconChevronRightOutline14, StateDot, type StateDotState, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkflowRunKey } from './locales.ts' +import type { + WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus, +} from './workflow-definition.ts' +import css from './WorkflowRunPanel.module.css' + +/** Navigation action injected from the plugin's own SessionsService access. */ +export interface WorkflowRunInjected { + readonly openSession: (id: SessionId) => void +} + +/** Complete keyed Chat renderer props. */ +export type WorkflowRunPanelProps = + PropsRuntime<'conversation.chat.node', 'workflow-run'> + & PropsLocale<'workflowRun'> + & WorkflowRunInjected + +const STATUS_KEYS = { + running: 'status.running', + completed: 'status.completed', + failed: 'status.failed', + cancelled: 'status.cancelled', + interrupted: 'status.interrupted', +} as const satisfies Record + +function dotState(status: WorkflowRunStatus): StateDotState { + switch (status) { + case 'running': return 'ongoing' + case 'completed': return 'done' + case 'failed': return 'error' + case 'cancelled': + case 'interrupted': return 'warning' + /* v8 ignore next -- WorkflowRunStatus is closed and every variant is handled above. */ + default: return status satisfies never + } +} + +function readablePhase(phase: string | null, t: WorkflowRunPanelProps['t']): string { + if (phase === null) return t('phase.unassigned') + return phase === '' ? t('phase.empty') : phase +} + +function readableMember(label: string, t: WorkflowRunPanelProps['t']): string { + return label === '' ? t('member.empty') : label +} + +function statusCount( + status: WorkflowRunStatus, + count: number, + t: WorkflowRunPanelProps['t'], +): string { + return t(`statusCount.${status}`, { count }) +} + +function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string { + const counts = new Map() + for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1) + const count = (status: WorkflowRunStatus): number => counts.get(status) ?? 0 + const active = (['running', 'failed', 'cancelled', 'interrupted'] as const) + .filter(status => count(status) > 0) + if (active.length === 0) return statusCount('completed', count('completed'), t) + const visible = active.includes('interrupted') && count('completed') > 0 + ? ['completed' as const, ...active] + : active + return visible.map(status => statusCount(status, count(status), t)).join(' · ') +} + +function handleDisclosureKey(event: KeyboardEvent, onToggle: () => void): void { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + onToggle() +} + +function RunHeader({ count, name, onToggle, open, status, t }: { + readonly count: number + readonly name: string + readonly onToggle: () => void + readonly open: boolean + readonly status: WorkflowRunStatus + readonly t: WorkflowRunPanelProps['t'] +}) { + return ( +
{ handleDisclosureKey(event, onToggle) }} + > + + {open ? : } + + {t('run.title', { name })} + + {t('run.members', { count })} + + + {t(STATUS_KEYS[status])} + +
+ ) +} + +function MemberRow({ member, navigable, openSession, t }: { + readonly member: WorkflowRunMemberData + readonly navigable: boolean + readonly openSession: WorkflowRunInjected['openSession'] + readonly t: WorkflowRunPanelProps['t'] +}) { + const name = readableMember(member.label, t) + const content = ( + <> + + {name} + {t(STATUS_KEYS[member.status])} + + ) + if (!navigable) { + return
{content}
+ } + return ( + + ) +} + +function PhaseSection({ phase, navigable, openSession, t }: { + readonly phase: WorkflowRunPhaseData + readonly navigable: ReadonlySet + readonly openSession: WorkflowRunInjected['openSession'] + readonly t: WorkflowRunPanelProps['t'] +}) { + const [open, setOpen] = useState(false) + const toggle = (): void => { setOpen(value => !value) } + return ( +
+
{ handleDisclosureKey(event, toggle) }} + > + + {open ? : } + + {readablePhase(phase.phase, t)} + + {t('run.members', { count: phase.members.length })} + {phaseStatusSummary(phase.members, t)} +
+ {open && ( +
+ {phase.members.map(member => ( + + ))} +
+ )} +
+ ) +} + +/** Render one durable workflow run with independent run and phase disclosure. */ +export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) { + const [open, setOpen] = useState(() => node.data.status === 'running') + const sessions = useSessions(value => value) + const navigable = useMemo(() => { + const ordinary = new Set(sessions.ids) + const result = new Set() + for (const phase of node.data.phases) { + for (const member of phase.members) { + const summary = sessions.byId[member.childId] + if (member.status === 'running' + && ordinary.has(member.childId) + && summary?.origin === 'subagent' + && summary.parentId === sessionId + && summary.running) { + result.add(member.childId) + } + } + } + return result + }, [node.data.phases, sessionId, sessions]) + return ( +
+ { setOpen(value => !value) }} + /> + {open && ( +
+ {node.data.phases.length === 0 + ? {t('run.empty')} + : node.data.phases.map(phase => ( + + ))} +
+ )} +
+ ) +} diff --git a/packages/client/ui-workflow-run/src/client/index.ts b/packages/client/ui-workflow-run/src/client/index.ts new file mode 100644 index 0000000000..8f8a2c5480 --- /dev/null +++ b/packages/client/ui-workflow-run/src/client/index.ts @@ -0,0 +1,38 @@ +/** Browser plugin for durable workflow-run Conversation Nodes. */ + +import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { WorkflowRunPanel, type WorkflowRunInjected } from './WorkflowRunPanel.tsx' +import { en, NS, type WorkflowRunKey, zh } from './locales.ts' +import { workflowRunDefinition } from './workflow-definition.ts' + +export type { WorkflowRunInjected, WorkflowRunPanelProps } from './WorkflowRunPanel.tsx' +export type { + WorkflowRunChatData, WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus, +} from './workflow-definition.ts' +export type { WorkflowRunKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Durable workflow-run node copy. */ + workflowRun: WorkflowRunKey + } +} + +/** Required services for Definition, keyed renderer, navigation, and copy. */ +export const inject = ['conversationEvents', 'slots', 'sessions', 'locale'] + +/** Register the workflow Definition, dictionary, and keyed Chat renderer. */ +export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(workflowRunDefinition) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workflow-run: dictionaries') + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'workflow-run', + locale: NS, + inject: (): WorkflowRunInjected => ({ + openSession: (id: SessionId) => { ctx.sessions.open(id) }, + }), + }, WorkflowRunPanel)) +} diff --git a/packages/client/ui-workflow-run/src/client/locales.ts b/packages/client/ui-workflow-run/src/client/locales.ts new file mode 100644 index 0000000000..71a7c2aa9b --- /dev/null +++ b/packages/client/ui-workflow-run/src/client/locales.ts @@ -0,0 +1,49 @@ +/** `workflowRun` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'workflowRun' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'run.title': '{name}', + 'run.members': '{count} 个成员', + 'run.empty': '没有启动成员', + 'phase.unassigned': '未分阶段', + 'phase.empty': '空阶段名', + 'statusCount.running': '运行中 {count}', + 'statusCount.completed': '已完成 {count}', + 'statusCount.failed': '失败 {count}', + 'statusCount.cancelled': '已取消 {count}', + 'statusCount.interrupted': '已中断 {count}', + 'member.empty': '空成员名', + 'member.open': '打开 {name}', + 'status.running': '运行中', + 'status.completed': '已完成', + 'status.failed': '失败', + 'status.cancelled': '已取消', + 'status.interrupted': '已中断', +} + +/** English dictionary (same key set). */ +export const en: Record = { + 'run.title': '{name}', + 'run.members': '{count} members', + 'run.empty': 'No members started', + 'phase.unassigned': 'Unphased', + 'phase.empty': 'Empty phase name', + 'statusCount.running': 'Running {count}', + 'statusCount.completed': 'Completed {count}', + 'statusCount.failed': 'Failed {count}', + 'statusCount.cancelled': 'Cancelled {count}', + 'statusCount.interrupted': 'Interrupted {count}', + 'member.empty': 'Empty member name', + 'member.open': 'Open {name}', + 'status.running': 'Running', + 'status.completed': 'Completed', + 'status.failed': 'Failed', + 'status.cancelled': 'Cancelled', + 'status.interrupted': 'Interrupted', +} + +/** Union of this namespace's dictionary keys. */ +export type WorkflowRunKey = keyof typeof zh diff --git a/packages/client/ui-workflow-run/src/client/workflow-definition.ts b/packages/client/ui-workflow-run/src/client/workflow-definition.ts new file mode 100644 index 0000000000..e6a4d2fec0 --- /dev/null +++ b/packages/client/ui-workflow-run/src/client/workflow-definition.ts @@ -0,0 +1,200 @@ +import type { + ChatConversationViewNode, ConversationLocation, ConversationNodeContext, + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, +} from '@deepseek-ai/dsh-tool-workflow/types' +import type { WorkflowAgentOutcome, WorkflowStopReason } from '@deepseek-ai/dsh-workflow/types' + +/** Status shown for a workflow, phase, or member. */ +export type WorkflowRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted' + +/** Final renderer data for one member. */ +export interface WorkflowRunMemberData { + readonly seq: number + readonly label: string + readonly childId: SessionId + readonly status: WorkflowRunStatus +} + +/** Final renderer data for one exact phase identity. */ +export interface WorkflowRunPhaseData { + readonly key: string + /** `null` is the absent field; the empty string remains a distinct identity. */ + readonly phase: string | null + readonly status: WorkflowRunStatus + readonly members: readonly WorkflowRunMemberData[] +} + +/** Final keyed Chat payload for one workflow run. */ +export interface WorkflowRunChatData { + readonly name: string + readonly status: WorkflowRunStatus + readonly memberCount: number + readonly phases: readonly WorkflowRunPhaseData[] +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Durable top-level workflow run and all members that actually started. */ + 'workflow-run': WorkflowRunChatData + } +} + +interface WorkflowMemberState extends ToolWorkflowAgentStartData { + readonly outcome?: WorkflowAgentOutcome +} + +interface WorkflowState { + readonly name: string + readonly stopReason?: WorkflowStopReason + readonly members: readonly WorkflowMemberState[] +} + +/** + * Build a collision-free phase key preserving absent versus empty identity. + * @param phase - exact phase string, or null for an omitted field. + * @returns the stable renderer key for that phase identity. + */ +export function workflowPhaseKey(phase: string | null): string { + return phase === null ? 'missing' : `value:${phase.length}:${phase}` +} + +function statusFromStopReason(stopReason: WorkflowStopReason): WorkflowRunStatus { + switch (stopReason) { + case 'completed': return 'completed' + case 'cancelled': return 'cancelled' + case 'error': return 'failed' + /* v8 ignore next -- WorkflowStopReason is closed and every variant is handled above. */ + default: return stopReason satisfies never + } +} + +function statusFromOutcome(outcome: WorkflowAgentOutcome): WorkflowRunStatus { + switch (outcome) { + case 'completed': return 'completed' + case 'cancelled': return 'cancelled' + case 'failed': return 'failed' + /* v8 ignore next -- WorkflowAgentOutcome is closed and every variant is handled above. */ + default: return outcome satisfies never + } +} + +function locationClosed(location: ConversationLocation | undefined): boolean { + if (location === undefined) return false + if (location.kind === 'step') { + return location.step.status === 'closed' || location.turn.status === 'closed' + } + return location.kind === 'turn' && location.turn.status === 'closed' +} + +function aggregateStatus(members: readonly WorkflowRunMemberData[]): WorkflowRunStatus { + if (members.some(member => member.status === 'running')) return 'running' + if (members.some(member => member.status === 'failed')) return 'failed' + if (members.some(member => member.status === 'cancelled')) return 'cancelled' + if (members.some(member => member.status === 'interrupted')) return 'interrupted' + return 'completed' +} + +function projectWorkflow( + context: ConversationNodeContext, +): WorkflowRunChatData | undefined { + const state = context.state + if (state === undefined) return undefined + const interrupted = state.stopReason === undefined + && locationClosed(context.start?.location ?? context.matches[0]?.location) + const phases = new Map() + for (const member of state.members) { + const phase = member.phase === undefined ? null : member.phase + const key = workflowPhaseKey(phase) + let group = phases.get(key) + if (group === undefined) { + group = { phase, members: [] } + phases.set(key, group) + } + group.members.push({ + seq: member.seq, + label: member.label, + childId: member.childId, + status: member.outcome === undefined + ? interrupted ? 'interrupted' : 'running' + : statusFromOutcome(member.outcome), + }) + } + const projectedPhases = [...phases].map(([key, phase]) => ({ + key, + phase: phase.phase, + status: aggregateStatus(phase.members), + members: phase.members, + })) + return { + name: state.name, + status: state.stopReason === undefined + ? interrupted ? 'interrupted' : 'running' + : statusFromStopReason(state.stopReason), + memberCount: state.members.length, + phases: projectedPhases, + } +} + +function updateAgentStart(state: WorkflowState, data: ToolWorkflowAgentStartData): WorkflowState { + return { ...state, members: [...state.members, data] } +} + +function updateAgentEnd(state: WorkflowState, data: ToolWorkflowAgentEndData): WorkflowState { + return { + ...state, + members: state.members.map(member => member.seq === data.seq + ? { ...member, outcome: data.outcome } + : member), + } +} + +/** Durable workflow event family folded into one keyed Chat node. */ +export const workflowRunDefinition: ConversationNodeDefinition = { + kind: 'workflow-run', + match: (event) => { + if (event.type === 'tool-workflow/run-start') return { id: String(event.data.runId), role: 'start' } + if (event.type === 'tool-workflow/agent-start' + || event.type === 'tool-workflow/agent-end' + || event.type === 'tool-workflow/run-end') { + return { id: String(event.data.runId), role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'tool-workflow/run-start') { + throw new Error('workflow-run start requires tool-workflow/run-start') + } + return { name: match.event.data.name, members: [] } + }, + update: (context, match) => { + if (match.event.type === 'tool-workflow/agent-start') { + return updateAgentStart(context.state, match.event.data) + } + if (match.event.type === 'tool-workflow/agent-end') { + return updateAgentEnd(context.state, match.event.data) + } + if (match.event.type === 'tool-workflow/run-end') { + return { ...context.state, stopReason: match.event.data.stopReason } + } + return context.state + }, + buildViewNode: (context, target): ChatConversationViewNode | null => { + if (target !== 'chat') return null + const data = projectWorkflow(context) + if (data === undefined || context.start === undefined) return null + return { + key: context.key, + kind: 'workflow-run', + id: context.id, + target: 'chat', + anchorSeq: context.start.event.seq, + location: context.start.location, + visibility: 'visible', + data, + } + }, +} diff --git a/packages/client/ui-workflow-run/src/css-modules.d.ts b/packages/client/ui-workflow-run/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-workflow-run/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-workflow-run/src/index.ts b/packages/client/ui-workflow-run/src/index.ts new file mode 100644 index 0000000000..3678bc9f9f --- /dev/null +++ b/packages/client/ui-workflow-run/src/index.ts @@ -0,0 +1,4 @@ +/** Durable workflow-run UI plugin, node half. */ + +/** Host plugin body; the feature is entirely browser-side. */ +export function apply(): void {} diff --git a/packages/client/ui-workflow-run/src/invariant.ts b/packages/client/ui-workflow-run/src/invariant.ts new file mode 100644 index 0000000000..7e5bfa2211 --- /dev/null +++ b/packages/client/ui-workflow-run/src/invariant.ts @@ -0,0 +1,24 @@ +/** Package-owned invariant companion for the workflow-run UI plugin. */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workflow-run' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-workflow-run-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the browser plugin contributes one effect-owned + * Conversation Definition, keyed renderer, and dictionary; tests prove their + * disposal and the Host tool package owns the durable event invariant. + */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx new file mode 100644 index 0000000000..3b7a2b3f79 --- /dev/null +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -0,0 +1,526 @@ +// @vitest-environment jsdom +import { Context, Service } from 'cordis' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ConversationEventRegistry, ConversationNodeAssembler, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { + ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition, + ConversationViewDefinition, ConversationViewNode, SessionId, SessionListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { + WorkflowRunPanel, type WorkflowRunInjected, type WorkflowRunPanelProps, +} from '../src/client/WorkflowRunPanel.tsx' +import { apply, inject } from '../src/client/index.ts' +import { zh } from '../src/client/locales.ts' +import { + workflowRunDefinition, type WorkflowRunChatData, +} from '../src/client/workflow-definition.ts' +import { apply as applyNode } from '../src/index.ts' +import { apply as applyInvariant } from '../src/invariant.ts' +import type {} from '../src/client/index.ts' + +afterEach(cleanup) + +const PARENT_ID = 'parent' as SessionId +const CHILD_ID = 'child-1' as SessionId + +interface ChatSnapshot { + readonly nodes: ReadonlyMap +} + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { return [workflowRunDefinition] } + fallbackEntry(): undefined { return undefined } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { return [chatViewDefinition] } +} + +const chatViewDefinition: ConversationViewDefinition = { + target: 'chat', + create: () => { + let nodes = new Map() + const snapshot = (): ChatSnapshot => ({ nodes }) + return { + empty: snapshot(), + replace: ({ nodes: values }) => { + nodes = new Map(values.map(node => [node.key, node])) + return snapshot() + }, + apply: ({ upserts }) => { + nodes = new Map(nodes) + for (const node of upserts) nodes.set(node.key, node) + return snapshot() + }, + } + }, +} + +function at(seq: number, type: string, data: unknown): ConversationEventInput { + return { event: { seq, time: seq * 100, type, data } as ConversationEventInput['event'], view: undefined } +} + +function matched(input: ConversationEventInput, role: ConversationMatch['role']): ConversationMatch { + return { ...input, role, location: { kind: 'unresolved' } } +} + +function assembler(entries: readonly ConversationEventInput[], hasMore = false): ConversationNodeAssembler { + const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) + value.replaceWindow(entries, hasMore) + value.flush() + return value +} + +function workflowData(value: ConversationNodeAssembler): WorkflowRunChatData | undefined { + const snapshot = value.snapshot('chat') as ChatSnapshot + return [...snapshot.nodes.values()][0]?.data as WorkflowRunChatData | undefined +} + +function completeEvents(): ConversationEventInput[] { + return [ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }), + at(4, 'tool-workflow/agent-start', { + runId: 'run-1', seq: 1, label: 'first', phase: '', childId: 'child-1', + }), + at(5, 'tool-workflow/agent-start', { + runId: 'run-1', seq: 2, label: 'second', childId: 'child-2', + }), + at(6, 'tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }), + at(7, 'tool-workflow/agent-end', { runId: 'run-1', seq: 2, outcome: 'failed' }), + at(8, 'tool-workflow/run-end', { runId: 'run-1', stopReason: 'error' }), + at(9, 'step/end', { turn: 1, step: 1 }), + at(10, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ] +} + +describe('workflow-run Conversation Definition', () => { + it('groups exact phase identities in first-member order and preserves terminal members', () => { + const value = assembler(completeEvents()) + const data = workflowData(value) + expect(data).toEqual({ + name: 'audit', + status: 'failed', + memberCount: 2, + phases: [ + { + key: 'value:0:', phase: '', status: 'completed', + members: [{ seq: 1, label: 'first', childId: 'child-1', status: 'completed' }], + }, + { + key: 'missing', phase: null, status: 'failed', + members: [{ seq: 2, label: 'second', childId: 'child-2', status: 'failed' }], + }, + ], + }) + const node = [...(value.snapshot('chat') as ChatSnapshot).nodes.values()][0]! + expect(node.anchorSeq).toBe(3) + expect(node.kind).toBe('workflow-run') + }) + + it('keeps an update-only tail pending until prepend supplies the unique start', () => { + const tail = completeEvents().slice(3) + const value = assembler(tail, true) + expect(workflowData(value)).toBeUndefined() + value.prepend(completeEvents().slice(0, 3), false) + value.flush() + expect(workflowData(value)).toEqual(workflowData(assembler(completeEvents()))) + }) + + it('produces the same final data through live append as complete replay', () => { + const events = completeEvents() + const value = assembler(events.slice(0, 3)) + for (const event of events.slice(3)) value.append(event) + value.flush() + expect(workflowData(value)).toEqual(workflowData(assembler(events))) + }) + + it('shows missing terminal facts as interrupted only after the owning Location closes', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool-workflow/run-start', { runId: 'run-1', name: 'audit' }), + at(4, 'tool-workflow/agent-start', { + runId: 'run-1', seq: 1, label: 'worker', childId: 'child-1', + }), + ]) + expect(workflowData(value)?.status).toBe('running') + value.append(at(5, 'step/end', { turn: 1, step: 1 })) + value.flush() + expect(workflowData(value)).toMatchObject({ + status: 'interrupted', + phases: [{ members: [{ status: 'interrupted' }] }], + }) + }) + + it('retains a zero-member run as its own completed node', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool-workflow/run-start', { runId: 'empty', name: 'empty' }), + at(4, 'tool-workflow/run-end', { runId: 'empty', stopReason: 'completed' }), + ]) + expect(workflowData(value)).toEqual({ + name: 'empty', status: 'completed', memberCount: 0, phases: [], + }) + }) + + it('folds same-phase cancellation and a turn-level interruption', () => { + const cancelled = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'tool-workflow/run-start', { runId: 'cancelled', name: 'cancelled' }), + at(3, 'tool-workflow/agent-start', { + runId: 'cancelled', seq: 1, label: 'one', phase: 'Research', childId: 'child-1', + }), + at(4, 'tool-workflow/agent-start', { + runId: 'cancelled', seq: 2, label: 'two', phase: 'Research', childId: 'child-2', + }), + at(5, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 1, outcome: 'cancelled' }), + at(6, 'tool-workflow/agent-end', { runId: 'cancelled', seq: 2, outcome: 'completed' }), + at(7, 'tool-workflow/run-end', { runId: 'cancelled', stopReason: 'cancelled' }), + ]) + expect(workflowData(cancelled)).toMatchObject({ + status: 'cancelled', + phases: [{ phase: 'Research', status: 'cancelled', members: [{ status: 'cancelled' }, { status: 'completed' }] }], + }) + + const interruptedTurn = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'tool-workflow/run-start', { runId: 'turn', name: 'turn' }), + at(3, 'tool-workflow/agent-start', { + runId: 'turn', seq: 1, label: 'open', childId: 'child-1', + }), + at(4, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + expect(workflowData(interruptedTurn)?.status).toBe('interrupted') + }) + + it('handles session/unresolved placement and defensive Definition calls', () => { + const sessionLevel = assembler([ + at(1, 'tool-workflow/run-start', { runId: 'session', name: 'session' }), + at(2, 'tool-workflow/agent-start', { + runId: 'session', seq: 1, label: 'open', childId: 'child-1', + }), + ]) + expect(workflowData(sessionLevel)?.status).toBe('running') + + const invalidStart = matched(at(1, 'tool-workflow/agent-start', { + runId: 'direct', seq: 1, label: 'member', childId: 'child-1', + }), 'start') + const emptyContext: Parameters[0] = { + key: 'workflow-run:direct', kind: 'workflow-run', id: 'direct', + matches: [invalidStart], start: invalidStart, state: undefined, current: new Map(), + } + const reader: Parameters[2] = { previous: () => undefined } + expect(() => workflowRunDefinition.start(emptyContext, invalidStart, reader)) + .toThrow('workflow-run start requires tool-workflow/run-start') + + const start = matched(at(2, 'tool-workflow/run-start', { runId: 'direct', name: 'direct' }), 'start') + const startedContext = { ...emptyContext, matches: [start], start } + const state = workflowRunDefinition.start(startedContext, start, reader) + const updateContext: Parameters[0] = { ...startedContext, state } + const unrelated = matched(at(3, 'turn/start', { turn: 1 }), 'update') + expect(workflowRunDefinition.update(updateContext, unrelated)).toBe(state) + expect(workflowRunDefinition.buildViewNode(updateContext, 'trajectory')).toBeNull() + expect(workflowRunDefinition.buildViewNode({ + ...updateContext, matches: [], start: undefined, + }, 'chat')).toBeNull() + const directNode = workflowRunDefinition.buildViewNode(updateContext, 'chat') as ChatConversationViewNode | null + if (directNode === null) throw new Error('expected direct workflow Chat node') + expect(directNode.kind).toBe('workflow-run') + expect((directNode.data as WorkflowRunChatData).status).toBe('running') + }) +}) + +function node(data: WorkflowRunChatData): WorkflowRunPanelProps['node'] { + return { + key: '12:workflow-runrun-1', + kind: 'workflow-run', + id: 'run-1', + target: 'chat', + anchorSeq: 3, + location: { kind: 'unresolved' }, + visibility: 'visible', + data, + } +} + +const phase = (overrides: Partial = {}): WorkflowRunChatData['phases'][number] => ({ + key: 'missing', + phase: null, + status: 'running', + members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: 'running' }], + ...overrides, +}) + +const listState = (overrides: Partial = {}): SessionListState => ({ + ids: [PARENT_ID, CHILD_ID], + byId: { + [PARENT_ID]: { + id: PARENT_ID, displayTitle: 'parent', running: true, blank: false, updatedAt: 0, + }, + [CHILD_ID]: { + id: CHILD_ID, displayTitle: 'child', parentId: PARENT_ID, origin: 'subagent', + running: true, blank: false, updatedAt: 0, + }, + }, + current: PARENT_ID, + phase: 'ready', + subagentsByParent: {}, + currentAddress: undefined, + ...overrides, +}) + +function panelProps(data: WorkflowRunChatData, sessions = listState(), openSession = vi.fn()): WorkflowRunPanelProps { + return { + node: node(data), + sessionId: PARENT_ID, + useSessions: selector => selector(sessions), + useSession: (() => undefined) as WorkflowRunPanelProps['useSession'], + useProjection: () => undefined, + useInput: () => { throw new Error('unused') }, + inputActions: { setDraft: () => {}, submit: () => {} } as unknown as WorkflowRunPanelProps['inputActions'], + useWorkspaces: (() => undefined) as WorkflowRunPanelProps['useWorkspaces'], + useTurnData: () => undefined, + selectedCallId: undefined, + cwd: undefined, + openFile: () => {}, + inspectCall: () => {}, + forkAt: () => {}, + loadImage: () => Promise.reject(new Error('unused')), + fileMentions: () => undefined, + openSession, + t: makeTranslate(zh), + } +} + +describe('WorkflowRunPanel', () => { + it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => { + const running: WorkflowRunChatData = { + name: 'audit', status: 'running', memberCount: 1, phases: [phase()], + } + const view = render() + expect(screen.getByText('未分阶段')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /^audit/ })) + expect(screen.queryByText('未分阶段')).toBeNull() + + const terminal: WorkflowRunChatData = { ...running, status: 'completed' } + view.rerender() + expect(screen.queryByText('未分阶段')).toBeNull() + + cleanup() + render() + expect(screen.queryByText('未分阶段')).toBeNull() + }) + + it('supports root keyboard disclosure and renders a zero-member running state', () => { + render() + const header = screen.getByRole('button', { name: /^keyboard/ }) + expect(header.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(header, { key: 'ArrowDown' }) + expect(header.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(header, { key: 'Enter' }) + expect(header.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(header, { key: ' ' }) + expect(header.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('Research')).toBeTruthy() + expect(screen.getByText('运行中 1')).toBeTruthy() + const phaseHeader = screen.getByRole('button', { name: /Research/ }) + fireEvent.keyDown(phaseHeader, { key: 'ArrowDown' }) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.keyDown(phaseHeader, { key: 'Enter' }) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(phaseHeader, { key: ' ' }) + expect(phaseHeader.getAttribute('aria-expanded')).toBe('false') + + cleanup() + render() + expect(screen.getByText('没有启动成员')).toBeTruthy() + }) + + it('keeps phase disclosure independent and preserves empty versus absent names', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) + expect(screen.getByText('空成员名')).toBeTruthy() + expect(screen.queryByText('second')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.getByText('second')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: /空阶段名/ })) + expect(screen.queryByText('空成员名')).toBeNull() + expect(screen.getByText('second')).toBeTruthy() + }) + + it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => { + const completed: WorkflowRunChatData = { + name: 'repo-audit', status: 'completed', memberCount: 1, + phases: [phase({ + status: 'completed', + members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }], + })], + } + const completedView = render() + const completedHeader = screen.getByRole('button', { name: /^repo-audit/ }) + expect(completedHeader.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(completedHeader) + expect(completedHeader.getAttribute('aria-expanded')).toBe('true') + completedView.unmount() + + const mixed: WorkflowRunChatData = { + name: 'repo-audit', status: 'failed', memberCount: 2, + phases: [phase({ + status: 'failed', + members: [ + { seq: 1, label: 'failed', childId: 'child-1' as SessionId, status: 'failed' }, + { seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' }, + ], + })], + } + const mixedView = render() + fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ })) + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.getByText('失败 1 · 已取消 1')).toBeTruthy() + expect([...mixedView.container.querySelectorAll('[data-member-status]')] + .map(row => row.getAttribute('data-member-status'))).toEqual(['failed', 'cancelled']) + expect(mixedView.container.querySelectorAll('[data-state="error"]')).toHaveLength(2) + expect(mixedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1) + mixedView.unmount() + + const interrupted: WorkflowRunChatData = { + name: 'repo-audit', status: 'interrupted', memberCount: 2, + phases: [ + phase({ + status: 'interrupted', + members: [ + { seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }, + { seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' }, + ], + }), + phase({ + key: 'interrupted-only', phase: 'Interrupted only', status: 'interrupted', + members: [{ + seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted', + }], + }), + ], + } + const interruptedView = render() + fireEvent.click(screen.getByRole('button', { name: /^repo-audit/ })) + expect(screen.getByText('已完成 1 · 已中断 1')).toBeTruthy() + expect(interruptedView.container.querySelector('[data-run-status="interrupted"]')).toBeTruthy() + expect(interruptedView.container.querySelectorAll('[data-state="warning"]')).toHaveLength(1) + }) + + it('opens only a running ordinary-list subagent proven to have this parent', () => { + const data: WorkflowRunChatData = { + name: 'audit', status: 'running', memberCount: 1, phases: [phase()], + } + const openSession = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + fireEvent.click(screen.getByRole('button', { name: '打开 worker' })) + expect(openSession).toHaveBeenCalledWith('child-1') + }) + + it.each([ + ['not in ordinary list', listState({ ids: [PARENT_ID] }), 'running'], + ['remote row', listState({ byId: { + ...listState().byId, + [CHILD_ID]: { ...listState().byId[CHILD_ID]!, origin: undefined }, + } }), 'running'], + ['wrong parent', listState({ byId: { + ...listState().byId, + [CHILD_ID]: { ...listState().byId[CHILD_ID]!, parentId: 'other' as SessionId }, + } }), 'running'], + ['list terminal', listState({ byId: { + ...listState().byId, + [CHILD_ID]: { ...listState().byId[CHILD_ID]!, running: false }, + } }), 'running'], + ['member terminal', listState(), 'completed'], + ] as const)('does not navigate when %s', (_name, sessions, memberStatus) => { + const data: WorkflowRunChatData = { + name: 'audit', status: 'running', memberCount: 1, + phases: [phase({ + status: memberStatus === 'running' ? 'running' : 'completed', + members: [{ + seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: memberStatus, + }], + })], + } + render() + fireEvent.click(screen.getByRole('button', { name: /未分阶段/ })) + expect(screen.queryByRole('button', { name: '打开 worker' })).toBeNull() + cleanup() + }) +}) + +class TestSessions extends Service { + readonly opened: SessionId[] = [] + constructor(ctx: Context) { super(ctx, 'sessions') } + open(id: SessionId): void { this.opened.push(id) } +} + +describe('plugin lifecycle', () => { + it('registers and removes the Definition and keyed renderer with its fiber', async () => { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(TestSessions).await() + ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.node': { kind: 'keyed', scope: 'session' } }, + } as never, () => null) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) + expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1) + const entry = ctx.slots.entries('conversation.chat.node')[0]! + const face = entry.inject?.() as unknown as WorkflowRunInjected + face.openSession(CHILD_ID) + expect((ctx.sessions as unknown as TestSessions).opened).toEqual([CHILD_ID]) + await fiber.dispose() + expect(ctx.conversationEvents.entries()).toEqual([]) + expect(ctx.slots.entries('conversation.chat.node')).toEqual([]) + + const replacement = ctx.plugin({ inject: [...inject], apply }) + await replacement.await() + expect(ctx.conversationEvents.entries().map(entry => entry.kind)).toEqual(['workflow-run']) + expect(ctx.slots.entries('conversation.chat.node')).toHaveLength(1) + await replacement.dispose() + }) + + it('keeps the node half inert and registers invariant ownership', async () => { + applyNode() + const registered: string[] = [] + const ctx = new Context() + ctx.provide('invariants') + ctx.set('invariants', { + register: (pkg: string) => { registered.push(pkg); return () => {} }, + } as never) + await applyInvariant(ctx) + expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run']) + }) +}) + +void ({} as ConversationViewNode) diff --git a/packages/client/ui-workflow-run/tsconfig.json b/packages/client/ui-workflow-run/tsconfig.json new file mode 100644 index 0000000000..d86b4edef3 --- /dev/null +++ b/packages/client/ui-workflow-run/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../core/session" + }, + { + "path": "../../workflow/workflow" + }, + { + "path": "../../workflow/tool-workflow" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-workflow-run/tsdown.config.ts b/packages/client/ui-workflow-run/tsdown.config.ts new file mode 100644 index 0000000000..c6cfded6a2 --- /dev/null +++ b/packages/client/ui-workflow-run/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-workflow-run', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index 209ac7758c..e50711118d 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/README.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 packages/workflow/tool-workflow/README.md -README.md: 29896bee0f78a1d1764c3908965325fcecbf7b53 -README.zh.md: 12e1ecd8932120c74384a289530954422ba145f2 +README.md: ba8283a6b517eea79e6c75674a906db01e4b5890 +README.zh.md: 2af8f5f8b8db2d5edf530d79dd81319846cfeeea diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index 29896bee0f..ba8283a6b5 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -12,6 +12,10 @@ Three parameters: `meta` (required identity data: `name`, `description`, and opt Collection is synchronous (like [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)): `execute` starts a run and awaits `run.result` inside a `try/finally` that always disposes the run, so the script and its children reach quiescence on every path. `exec.signal` is bridged to `run.cancel()` (including the already-aborted-before-start case). A non-`completed` stop reason maps to an `isError` result reporting the reason—never partial output as success; a parse/meta failure thrown synchronously by `start()` becomes an `isError` the model can correct from. Completion returns canonical `{ runId, agentsStarted, result }`; the Native renderer preserves the meta name, agent count, and JSON value, truncating only that projection at `maxResultChars`. +For a root transport execution (`exec.parent` absent), the tool also projects the run into the calling Agent's Session: run-start after `start()` returns, matching member starts and endings filtered by `run.id`, then run-end only after `run.result` is available and `dispose()` has reached quiescence. Nested transport calls execute normally but write no workflow record. The first failed Session append disables later recording for that run, emits one warning, and leaves either no record or a legal continuous prefix without changing the tool result or cleanup. + +The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes. + ## Render intent Decided up front (per the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)): a `generic` card titled `workflow: `, read directly from `args.meta.name` (presentation is a pure function of args and does not ask the engine to parse); the script text rides as `rawInput`. The result keeps the generic card. @@ -78,3 +82,4 @@ Append-only; newly visible content follows the reusable request prefix and does - **The parent turn blocks until the whole workflow settles** — there is no background start/poll surface, and cancellation discards partial output as an error. - **`args` must be an object and Native result text is bounded** — callers wrap top-level arrays/scalars in a field; the canonical workflow result remains complete, while JSON beyond `maxResultChars` is truncated in the model-facing projection rather than stored behind a retrieval handle. - **Workflow policy is fixed per tool registration** — provider selection, caps, and tool name are deployment config, not model-call arguments. +- **Durable records are top-level and observational** — nested Code Mode dispatches are not recorded, and a recording failure intentionally degrades to an incomplete prefix rather than changing execution. diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 12e1ecd893..2af8f5f8b8 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -12,6 +12,10 @@ 收集是同步的(类似 [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)):`execute` 启动运行并等待 `run.result`;这些操作位于 `try/finally` 中,该结构总会 dispose(资源释放)运行,使脚本及其子 agent(智能体)在每条路径上完全停稳。`exec.signal` 会桥接到 `run.cancel()`,包括启动前已经中止的情况。非 `completed` 结束原因会映射为报告原因的 `isError` 结果,绝不会把局部输出当作成功;`start()` 同步抛出的解析/meta 失败会变成模型可据以修正的 `isError`。完成时返回规范值 `{ runId, agentsStarted, result }`;Native 渲染器保留 meta 名称、agent 数量和 JSON 值,只会在 `maxResultChars` 处截断该投影。 +对于根 transport 执行(`exec.parent` 缺省),工具还会把运行投影到调用 Agent 的 Session:`start()` 返回后写 run-start,只记录 `run.id` 匹配的成员开始与结束,并且只在 `run.result` 已取得且 `dispose()` 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写工作流记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。 + +浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。 + ## 渲染意图 渲染意图预先确定(见[渲染意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)):使用一个 `generic` 卡片,标题为 `workflow: `,直接从 `args.meta.name` 读取(呈现是参数的纯函数,不要求引擎解析);脚本文本作为 `rawInput` 携带。结果继续使用 generic 卡片。 @@ -78,3 +82,4 @@ Use the tool ONLY when the user explicitly asks for a workflow or for - **父级轮次会阻塞到整个工作流结算**:没有后台启动/轮询接口,取消会把局部输出作为错误丢弃。 - **`args` 必须是对象,Native 结果文本有界**:调用方把顶层数组/标量包装到字段中;规范工作流结果保持完整,超过 `maxResultChars` 的 JSON 会在面向模型的投影中截断,而不是存储在检索句柄背后。 - **每次工具注册的工作流策略固定**:提供方选择、上限和工具名称属于部署配置,不是模型调用参数。 +- **持久记录只覆盖顶层且只供观察**:嵌套 Code Mode dispatch 不记录;记录故障会刻意退化为不完整前缀,而不改变执行。 diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 705e4f6e08..dc1c1be709 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -28,6 +33,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-workflow": "^0.0.1", diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 6c1e9b19bb..b815a776c8 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -15,8 +15,15 @@ import z from 'schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { JsonValue } from '@deepseek-ai/dsh-session' -import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow' +import type { JsonValue, Session, SessionEventMap } from '@deepseek-ai/dsh-session' +import type { + WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun, + WorkflowRunId, WorkflowRunInfo, WorkflowStopReason, +} from '@deepseek-ai/dsh-workflow' +import type { + ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, + ToolWorkflowRunEndData, ToolWorkflowRunStartData, +} from './types.ts' // Declaration merge only: makes ctx.systemPrompt visible for the section registration. import type {} from '@deepseek-ai/dsh-system-prompt' @@ -38,6 +45,114 @@ export const Config: z = z.object({ type ResolvedConfig = Required +type BufferedWorkflowEvent = + | { readonly kind: 'agent-start'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentInfo } + | { readonly kind: 'agent-end'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentEndInfo } + +interface WorkflowRecorder { + bind(run: WorkflowRun): void + finish(stopReason: WorkflowStopReason): void + dispose(): void +} + +interface ToolWorkflowRecordEventMap { + 'tool-workflow/run-start': ToolWorkflowRunStartData + 'tool-workflow/agent-start': ToolWorkflowAgentStartData + 'tool-workflow/agent-end': ToolWorkflowAgentEndData + 'tool-workflow/run-end': ToolWorkflowRunEndData +} + +/** Render a contained recording failure without trusting the thrown value. */ +function renderRecordingError(error: unknown): string { + try { + return String(error) + } catch { + return '[unrenderable thrown value]' + } +} + +/** + * Project one top-level workflow run into its parent Session without letting + * recording failure affect tool execution. Listeners are installed before + * `start()` so even a synchronous provider cannot outrun the recorder. + */ +function createWorkflowRecorder(ctx: Context, session: Session): WorkflowRecorder { + let runId: WorkflowRunId | undefined + let enabled = true + const buffered: BufferedWorkflowEvent[] = [] + // These four package-owned events are all log-only. Narrowing the generic + // append face here lets TypeScript discharge Session.append's conditional + // surface-options tuple once for the complete closed event set. + const appendRecord = session.append.bind(session) as ( + type: Type, + data: SessionEventMap[Type], + ) => void + + const append = ( + type: Type, + data: SessionEventMap[Type], + ): void => { + if (!enabled) return + try { + appendRecord(type, data) + } catch (error: unknown) { + enabled = false + ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`) + } + } + + const record = (event: BufferedWorkflowEvent): void => { + if (runId === undefined) { + buffered.push(event) + return + } + if (event.info.id !== runId) return + if (event.kind === 'agent-start') { + const data: ToolWorkflowAgentStartData = { + runId, + seq: event.agent.seq, + label: event.agent.label, + ...event.agent.phase === undefined ? {} : { phase: event.agent.phase }, + childId: event.agent.childId, + } + append('tool-workflow/agent-start', data) + return + } + const data: ToolWorkflowAgentEndData = { + runId, + seq: event.agent.seq, + outcome: event.agent.outcome, + } + append('tool-workflow/agent-end', data) + } + + const disposeStart = ctx.on('workflow/agent-start', (info, agent) => { + record({ kind: 'agent-start', info, agent }) + }) + const disposeEnd = ctx.on('workflow/agent-end', (info, agent) => { + record({ kind: 'agent-end', info, agent }) + }) + + return { + bind(run) { + runId = run.id + append('tool-workflow/run-start', { runId, name: run.meta.name }) + for (const event of buffered) record(event) + buffered.length = 0 + }, + finish(stopReason) { + /* v8 ignore next -- execute binds every returned run before result settlement can call finish. */ + if (runId === undefined) return + append('tool-workflow/run-end', { runId, stopReason }) + }, + dispose() { + disposeStart() + disposeEnd() + buffered.length = 0 + }, + } +} + /** * The script-authoring contract, embedded in the tool description. This IS the * model-facing spec: the meta block, the hooks and their exact semantics, and @@ -188,13 +303,23 @@ export function apply(ctx: Context, config: Config): void { // Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw // synchronously here and become isError results via the registry — the // model sees the violation list and can correct the call. - const run: WorkflowRun = ctx.workflows.start({ - script: args.script, - meta: args.meta, - ...args.args !== undefined ? { args: args.args } : {}, - parent, - signal: exec.signal, - }) + const recorder = exec.parent === undefined + ? createWorkflowRecorder(ctx, parent.session) + : undefined + let run: WorkflowRun + try { + run = ctx.workflows.start({ + script: args.script, + meta: args.meta, + ...args.args !== undefined ? { args: args.args } : {}, + parent, + signal: exec.signal, + }) + } catch (error: unknown) { + recorder?.dispose() + throw error + } + recorder?.bind(run) // Bridge the tool's abort signal to the run: if the parent step is aborted while the // script is in flight, cancel the whole run. The signal also enters the engine directly, but @@ -202,8 +327,9 @@ export function apply(ctx: Context, config: Config): void { const onAbort = (): void => { run.cancel('parent step aborted') } exec.signal.addEventListener('abort', onAbort, { once: true }) + let result: WorkflowResult | undefined try { - const result = await run.result + result = await run.result const error = stopReasonError(result) if (error !== undefined) { // Map a non-clean finish to an isError result (the registry turns a @@ -217,8 +343,15 @@ export function apply(ctx: Context, config: Config): void { } } finally { exec.signal.removeEventListener('abort', onAbort) - // Always reach run quiescence — never leak a live script or children. - await run.dispose() + try { + // Keep member listeners alive through disposal: an engine may + // synthesize cancelled member endings while reaching quiescence. + await run.dispose() + /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ + if (result !== undefined) recorder?.finish(result.stopReason) + } finally { + recorder?.dispose() + } } }, presentCall: args => presentWorkflowCall(args), diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 5f3ebc68ce..5fb14908ca 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -1,30 +1,158 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tool-workflow`. - * @module @deepseek-ai/dsh-tool-workflow/invariant - */ +/** Package-owned durable workflow-record invariants. @module @deepseek-ai/dsh-tool-workflow/invariant */ -/* jscpd:ignore-start */ import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type {} from './types.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow' /** Cordis companion plugin name. */ export const name = 'tool-workflow-invariant' -/** Service required before the companion can reserve package ownership. */ +/** Services required to validate existing and newly appended Session logs. */ export const inject = ['invariants'] -/** - * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution - * relations are owned by the capability seam it calls. - */ -const install: InvariantInstaller = () => {} +interface RunTrace { + ended: boolean + readonly members: Map +} -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ +type WorkflowTrace = Map + +/** Clone the independent fold before validating one candidate append. */ +function cloneTrace(source: WorkflowTrace): WorkflowTrace { + return new Map([...source].map(([runId, run]) => [runId, { + ended: run.ended, + members: new Map(run.members), + }])) +} + +/** Require a durable opaque identity to be a non-empty string. */ +function stringId(value: unknown, label: string, fail: InvariantFailure): string { + if (typeof value !== 'string' || value.length === 0) fail(`${label} must be a non-empty string`) + return value +} + +/** Require one workflow member's 1-based sequence identity. */ +function memberSeq(value: unknown, fail: InvariantFailure): number { + if (!Number.isSafeInteger(value) || (value as number) < 1) { + fail('tool-workflow member seq must be a positive safe integer') + } + return value as number +} + +/** Read one plain payload field without trusting restored plugin data. */ +function recordOf(event: SessionEvent, fail: InvariantFailure): Record { + const data: unknown = event.data + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + fail(`${event.type} data must be a JSON object`) + } + return data as Record +} + +/** Require the named run to exist and remain open. */ +function openRun(trace: WorkflowTrace, runId: string, eventType: string, fail: InvariantFailure): RunTrace { + const run = trace.get(runId) + if (run === undefined) fail(`${eventType} has no matching tool-workflow/run-start for run ${runId}`) + if (run.ended) fail(`${eventType} appears after tool-workflow/run-end for run ${runId}`) + return run +} + +/** Advance the workflow-record fold with one relevant Session event. */ +function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void { + if (!event.type.startsWith('tool-workflow/')) return + const data = recordOf(event, fail) + const runId = stringId(data.runId, `${event.type} runId`, fail) + + switch (event.type) { + case 'tool-workflow/run-start': { + if (typeof data.name !== 'string' || data.name.length === 0) { + fail('tool-workflow/run-start name must be a non-empty string') + } + if (trace.has(runId)) fail(`tool-workflow/run-start repeats run ${runId}`) + trace.set(runId, { ended: false, members: new Map() }) + return + } + case 'tool-workflow/agent-start': { + const run = openRun(trace, runId, event.type, fail) + const seq = memberSeq(data.seq, fail) + if (typeof data.label !== 'string') fail('tool-workflow/agent-start label must be a string') + if (data.phase !== undefined && typeof data.phase !== 'string') { + fail('tool-workflow/agent-start phase must be a string when present') + } + stringId(data.childId, 'tool-workflow/agent-start childId', fail) + if (run.members.has(seq)) fail(`tool-workflow/agent-start repeats member seq ${seq} in run ${runId}`) + run.members.set(seq, false) + return + } + case 'tool-workflow/agent-end': { + const run = openRun(trace, runId, event.type, fail) + const seq = memberSeq(data.seq, fail) + if (data.outcome !== 'completed' && data.outcome !== 'failed' && data.outcome !== 'cancelled') { + fail(`tool-workflow/agent-end outcome ${String(data.outcome)} is invalid`) + } + const ended = run.members.get(seq) + if (ended === undefined) fail(`tool-workflow/agent-end has no matching member seq ${seq} in run ${runId}`) + if (ended) fail(`tool-workflow/agent-end repeats member seq ${seq} in run ${runId}`) + run.members.set(seq, true) + return + } + case 'tool-workflow/run-end': { + const run = openRun(trace, runId, event.type, fail) + if (data.stopReason !== 'completed' && data.stopReason !== 'cancelled' && data.stopReason !== 'error') { + fail(`tool-workflow/run-end stopReason ${String(data.stopReason)} is invalid`) + } + const openMembers = [...run.members].filter(([, ended]) => !ended).map(([seq]) => seq) + if (openMembers.length > 0) { + fail(`tool-workflow/run-end leaves member seq ${openMembers.join(', ')} open in run ${runId}`) + } + run.ended = true + return + } + default: + fail(`unknown tool-workflow event type ${event.type}`) + } +} + +/** Apply one cold-load or live-append candidate through the package reporter. */ +function applyChecked(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void { + applyEvent(trace, event, fail) +} + +/** Install an independent incremental fold over every attached Session. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const traces = new WeakMap() + const staged = new WeakMap() + + const seed = (session: Session): WorkflowTrace => { + const trace: WorkflowTrace = new Map() + for (const event of session.events) applyChecked(trace, event, fail) + traces.set(session, trace) + return trace + } + /* v8 ignore next -- session/event always follows list() or session/created seeding. */ + const traceFor = (session: Session): WorkflowTrace => traces.get(session) ?? seed(session) + + for (const session of ctx.sessions.list()) seed(session) + ctx.on('session/created', (session) => { seed(session) }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + const trace = cloneTrace(traceFor(session)) + applyChecked(trace, event, fail) + staged.set(event, { session, trace }) + }, { global: true }) + ctx.on('session/event', (session, event) => { + const candidate = staged.get(event) + /* v8 ignore next 2 -- internal/dispatch stages the exact session/event callback arguments. */ + if (candidate === undefined || candidate.session !== session) { + return fail('session/event reached publication without matching workflow-record validation') + } + staged.delete(event) + traces.set(session, candidate.trace) + }, { global: true }) +}, { inject: ['sessions'] }) + +/** Register this package's invariant companion. */ export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/workflow/tool-workflow/src/types.ts b/packages/workflow/tool-workflow/src/types.ts new file mode 100644 index 0000000000..c184404939 --- /dev/null +++ b/packages/workflow/tool-workflow/src/types.ts @@ -0,0 +1,64 @@ +/** + * Browser-safe durable workflow-record events written by the model-facing + * workflow tool into its calling parent Session. + * + * @module @deepseek-ai/dsh-tool-workflow/types + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + WorkflowAgentOutcome, WorkflowRunId, WorkflowStopReason, +} from '@deepseek-ai/dsh-workflow/types' + +/** Opens one durable top-level workflow run record. */ +export interface ToolWorkflowRunStartData { + readonly runId: WorkflowRunId + readonly name: string +} + +/** Records one workflow member after its child Session is published. */ +export interface ToolWorkflowAgentStartData { + readonly runId: WorkflowRunId + readonly seq: number + readonly label: string + readonly phase?: string + readonly childId: SessionId +} + +/** Settles one previously started workflow member. */ +export interface ToolWorkflowAgentEndData { + readonly runId: WorkflowRunId + readonly seq: number + readonly outcome: WorkflowAgentOutcome +} + +/** Settles one workflow run after its live resources reach quiescence. */ +export interface ToolWorkflowRunEndData { + readonly runId: WorkflowRunId + readonly stopReason: WorkflowStopReason +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Opens one top-level workflow record. + * @param data - stable run identity and display name. + */ + 'tool-workflow/run-start': ToolWorkflowRunStartData + /** + * Records one published workflow member. + * @param data - run identity, member sequence, display identity, and child Session. + */ + 'tool-workflow/agent-start': ToolWorkflowAgentStartData + /** + * Records one member settlement. + * @param data - run identity, paired member sequence, and outcome. + */ + 'tool-workflow/agent-end': ToolWorkflowAgentEndData + /** + * Closes one workflow record after cleanup. + * @param data - stable run identity and terminal reason. + */ + 'tool-workflow/run-end': ToolWorkflowRunEndData + } +} diff --git a/packages/workflow/tool-workflow/tests/invariant.spec.ts b/packages/workflow/tool-workflow/tests/invariant.spec.ts new file mode 100644 index 0000000000..11d2fe94e7 --- /dev/null +++ b/packages/workflow/tool-workflow/tests/invariant.spec.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import { WorkflowRunId, type WorkflowRunId as WorkflowRunIdType } from '@deepseek-ai/dsh-workflow/types' +import * as ToolWorkflowInvariant from '../src/invariant.ts' +import type {} from '../src/types.ts' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(ToolWorkflowInvariant) + return ctx +} + +describe('durable workflow-record invariants', () => { + it('accepts interleaved complete runs and an unfinished continuous prefix', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('workflow-record-valid')) + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const first = WorkflowRunId('first') + const second = WorkflowRunId('second') + const third = WorkflowRunId('third') + session.append('tool-workflow/run-start', { runId: first, name: 'first' }) + session.append('tool-workflow/run-start', { runId: second, name: 'second' }) + session.append('tool-workflow/agent-start', { + runId: second, seq: 1, label: '', phase: '', childId: SessionId('child'), + }) + session.append('tool-workflow/run-end', { runId: first, stopReason: 'completed' }) + session.append('tool-workflow/agent-end', { runId: second, seq: 1, outcome: 'cancelled' }) + session.append('tool-workflow/run-end', { runId: second, stopReason: 'cancelled' }) + session.append('tool-workflow/run-start', { runId: third, name: 'third' }) + session.append('tool-workflow/agent-start', { + runId: third, seq: 1, label: 'failed', childId: SessionId('failed-child'), + }) + session.append('tool-workflow/agent-end', { runId: third, seq: 1, outcome: 'failed' }) + session.append('tool-workflow/run-end', { runId: third, stopReason: 'error' }) + session.append('tool-workflow/run-start', { runId: WorkflowRunId('prefix'), name: 'prefix' }) + expect(() => session.append('tool-workflow/agent-start', { + runId: WorkflowRunId('prefix'), seq: 1, label: 'open', childId: SessionId('open-child'), + })).not.toThrow() + }) + + it('rejects a malformed candidate before commit and keeps the fold reusable', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('workflow-record-invalid')) + const runId = WorkflowRunId('run') + session.append('tool-workflow/run-start', { runId, name: 'run' }) + const before = session.seq + expect(() => session.append('tool-workflow/agent-end', { + runId, seq: 1, outcome: 'completed', + })).toThrow(expect.objectContaining>({ + code: 'INVARIANT', + packageName: '@deepseek-ai/dsh-tool-workflow', + })) + expect(session.seq).toBe(before) + expect(() => session.append('tool-workflow/run-end', { + runId, stopReason: 'completed', + })).not.toThrow() + }) + + type Mutation = (session: Session, runId: WorkflowRunIdType) => void + const appendRaw = (session: Session, type: string, data: unknown): void => { + const append = session.append.bind(session) as (eventType: string, eventData: unknown) => unknown + append(type, data) + } + const invalidCases: readonly [string, Mutation, RegExp][] = [ + ['null event data', (session) => { + appendRaw(session, 'tool-workflow/run-start', null) + }, /data must be a JSON object/], + ['primitive event data', (session) => { + appendRaw(session, 'tool-workflow/run-start', 1) + }, /data must be a JSON object/], + ['array event data', (session) => { + appendRaw(session, 'tool-workflow/run-start', []) + }, /data must be a JSON object/], + ['numeric run id', (session) => { + session.append('tool-workflow/agent-start', { + runId: 1 as never, seq: 1, label: 'bad', childId: SessionId('child'), + }) + }, /runId must be a non-empty string/], + ['empty run id', (session) => { + session.append('tool-workflow/agent-start', { + runId: WorkflowRunId(''), seq: 1, label: 'bad', childId: SessionId('child'), + }) + }, /runId must be a non-empty string/], + ['empty run name', (session) => { + session.append('tool-workflow/run-start', { runId: WorkflowRunId('empty-name'), name: '' }) + }, /name must be a non-empty string/], + ['non-string run name', (session) => { + session.append('tool-workflow/run-start', { runId: WorkflowRunId('bad-name'), name: 1 as never }) + }, /name must be a non-empty string/], + ['duplicate run', (session, runId) => { + session.append('tool-workflow/run-start', { runId, name: 'again' }) + }, /repeats run/], + ['missing run', (session) => { + session.append('tool-workflow/agent-start', { + runId: WorkflowRunId('missing'), seq: 1, label: 'bad', childId: SessionId('child'), + }) + }, /no matching tool-workflow\/run-start/], + ['non-positive member seq', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 0, label: 'bad', childId: SessionId('child'), + }) + }, /positive safe integer/], + ['non-integer member seq', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1.5, label: 'bad', childId: SessionId('child'), + }) + }, /positive safe integer/], + ['non-string member label', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 1 as never, childId: SessionId('child'), + }) + }, /label must be a string/], + ['non-string member phase', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'bad', phase: 1 as never, childId: SessionId('child'), + }) + }, /phase must be a string/], + ['empty child id', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'bad', childId: SessionId(''), + }) + }, /childId must be a non-empty string/], + ['duplicate member start', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'one', childId: SessionId('child'), + }) + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'two', childId: SessionId('child-2'), + }) + }, /repeats member seq/], + ['invalid member outcome', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'one', childId: SessionId('child'), + }) + session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'unknown' as never }) + }, /outcome unknown is invalid/], + ['duplicate member end', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'one', childId: SessionId('child'), + }) + session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' }) + session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' }) + }, /repeats member seq/], + ['run end with an open member', (session, runId) => { + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'open', childId: SessionId('child'), + }) + session.append('tool-workflow/run-end', { runId, stopReason: 'completed' }) + }, /leaves member seq 1 open/], + ['invalid run stop reason', (session, runId) => { + session.append('tool-workflow/run-end', { runId, stopReason: 'unknown' as never }) + }, /stopReason unknown is invalid/], + ['event after run end', (session, runId) => { + session.append('tool-workflow/run-end', { runId, stopReason: 'completed' }) + session.append('tool-workflow/agent-start', { + runId, seq: 1, label: 'late', childId: SessionId('child'), + }) + }, /appears after/], + ['unknown workflow event', (session, runId) => { + appendRaw(session, 'tool-workflow/unknown', { runId }) + }, /unknown tool-workflow event type/], + ] + + it.each(invalidCases)('rejects %s', async (_name, mutate, pattern) => { + const ctx = await setup() + const session = ctx.sessions.create() + const runId = WorkflowRunId('run') + session.append('tool-workflow/run-start', { runId, name: 'run' }) + expect(() => { mutate(session, runId) }).toThrow(pattern) + }) + + it('validates existing cold history while allowing an unfinished prefix', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const valid = ctx.sessions.create(SessionId('workflow-record-cold-valid')) + valid.append('tool-workflow/run-start', { runId: WorkflowRunId('valid'), name: 'valid' }) + valid.append('tool-workflow/agent-start', { + runId: WorkflowRunId('valid'), seq: 1, label: 'open', childId: SessionId('child'), + }) + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(ToolWorkflowInvariant)).resolves.toBeDefined() + + const brokenCtx = new Context() + await brokenCtx.plugin(SessionStore) + const broken = brokenCtx.sessions.create(SessionId('workflow-record-cold-invalid')) + broken.append('tool-workflow/run-start', { runId: WorkflowRunId('broken'), name: 'broken' }) + broken.append('tool-workflow/run-end', { runId: WorkflowRunId('broken'), stopReason: 'completed' }) + broken.append('tool-workflow/agent-start', { + runId: WorkflowRunId('broken'), seq: 1, label: 'late', childId: SessionId('late'), + }) + await brokenCtx.plugin(InvariantService, { enabled: true }) + await expect(brokenCtx.plugin(ToolWorkflowInvariant)).rejects.toThrow(/appears after/) + }) +}) diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index a61862cffd..ab1fd05a8d 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -3,15 +3,18 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' -import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' -import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' +import type { + WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun, + WorkflowRunId as WorkflowRunIdType, WorkflowStartRequest, +} from '@deepseek-ai/dsh-workflow' import { CallId } from '@deepseek-ai/dsh-llm' import SubagentService from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as toolWorkflow from '../src/index.ts' -import { SessionId } from '@deepseek-ai/dsh-session' +import { Session, SessionId } from '@deepseek-ai/dsh-session' const testToolSignal = new AbortController().signal @@ -20,30 +23,62 @@ class StubEngine extends WorkflowService { requests: WorkflowStartRequest[] = [] cancels: string[] = [] disposed = 0 + disposeBarrier: Promise | undefined settle!: (result: WorkflowResult) => void + readonly settlements = new Map void>() startError: Error | undefined + emitMemberDuringStart = false start(request: WorkflowStartRequest): WorkflowRun { if (this.startError) throw this.startError this.requests.push(request) + const id = WorkflowRunId(`run-${this.requests.length}`) const result = new Promise((resolve) => { this.settle = resolve }) + this.settlements.set(id, this.settle) + if (this.emitMemberDuringStart) { + const info = { id, meta: request.meta } + const member = { seq: 1, label: 'synchronous', childId: SessionId('sync-child') } + this.emitWorkflowEvent('workflow/agent-start', info, member) + this.emitWorkflowEvent('workflow/agent-end', info, { ...member, outcome: 'completed' }) + } request.signal?.addEventListener('abort', () => { this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 }) }, { once: true }) return { - id: WorkflowRunId('run-1'), - meta: { name: 'stub-flow', description: 'd' }, + id, + meta: request.meta, result, cancel: (reason?: string) => { this.cancels.push(reason ?? 'cancelled') this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 }) }, - dispose: () => { + dispose: async () => { this.disposed += 1 - return Promise.resolve() + await this.disposeBarrier + this.settlements.delete(id) }, } } + + settleRun(id: WorkflowRunIdType, result: WorkflowResult): void { + const settle = this.settlements.get(id) + if (settle === undefined) throw new Error(`unknown stub workflow ${id}`) + settle(result) + } + + agentStart(id: WorkflowRunIdType, agent: WorkflowAgentInfo): void { + this.emitWorkflowEvent('workflow/agent-start', { + id, + meta: this.requests[Number(String(id).slice(4)) - 1]!.meta, + }, agent) + } + + agentEnd(id: WorkflowRunIdType, agent: WorkflowAgentEndInfo): void { + this.emitWorkflowEvent('workflow/agent-end', { + id, + meta: this.requests[Number(String(id).slice(4)) - 1]!.meta, + }, agent) + } } async function setup(config?: { toolName?: string; maxResultChars?: number }) { @@ -53,14 +88,19 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) { await ctx.plugin(StubEngine) await ctx.plugin(toolWorkflow, config ?? {}) const engine = ctx.workflows as StubEngine - const parent = { id: SessionId('caller'), options: {} } as unknown as Agent - return { ctx, engine, parent } + const session = Session.create(SessionId('caller')) + const parent = { id: session.id, options: {}, session } as unknown as Agent + return { ctx, engine, parent, session } } const SCRIPT = 'return 1' const META = { name: 'audit', description: 'd' } -function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise { +function execute(ctx: Context, args: unknown, extra?: { + agent?: Agent + signal?: AbortSignal + parent?: ToolExecutionToken +}): Promise { return ctx.tools.execute({ signal: testToolSignal, callId: CallId('call-1'), @@ -68,6 +108,7 @@ function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: arguments: args, ...extra?.agent ? { agent: extra.agent } : {}, ...extra?.signal ? { signal: extra.signal } : {}, + ...extra?.parent ? { parent: extra.parent } : {}, }) } @@ -90,6 +131,167 @@ describe('dsh-tool-workflow', () => { expect(engine.disposed).toBe(1) }) + it('records one top-level run and its members in the calling Session after cleanup', async () => { + const { ctx, engine, parent, session } = await setup() + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + const runId = WorkflowRunId('run-1') + engine.agentStart(runId, { + seq: 1, + label: '', + phase: '', + childId: SessionId('child-1'), + }) + engine.agentEnd(runId, { + seq: 1, + label: '', + phase: '', + childId: SessionId('child-1'), + outcome: 'completed', + }) + engine.settleRun(runId, { value: 1, stopReason: 'completed', agentsStarted: 1 }) + expect((await pending).isError).toBe(false) + expect(engine.disposed).toBe(1) + expect(session.events.map(event => [event.type, event.data])).toEqual([ + ['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }], + ['tool-workflow/agent-start', { + runId: 'run-1', seq: 1, label: '', phase: '', childId: 'child-1', + }], + ['tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }], + ['tool-workflow/run-end', { runId: 'run-1', stopReason: 'completed' }], + ]) + }) + + it('writes run-end only after run disposal reaches quiescence', async () => { + const { ctx, engine, parent, session } = await setup() + const barrier = Promise.withResolvers() + engine.disposeBarrier = barrier.promise + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + engine.settleRun(WorkflowRunId('run-1'), { + value: null, stopReason: 'completed', agentsStarted: 0, + }) + await vi.waitFor(() => { expect(engine.disposed).toBe(1) }) + expect(session.events.map(event => event.type)).toEqual(['tool-workflow/run-start']) + barrier.resolve(undefined) + expect((await pending).isError).toBe(false) + expect(session.events.map(event => event.type)).toEqual([ + 'tool-workflow/run-start', 'tool-workflow/run-end', + ]) + }) + + it('records zero-member and concurrent runs independently', async () => { + const { ctx, engine, parent, session } = await setup() + const first = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'first' } }, { agent: parent }) + const second = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'second' } }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) }) + const secondId = WorkflowRunId('run-2') + engine.agentStart(secondId, { + seq: 1, label: 'member', childId: SessionId('child-2'), + }) + engine.agentEnd(secondId, { + seq: 1, label: 'member', childId: SessionId('child-2'), outcome: 'failed', + }) + engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 }) + engine.settleRun(secondId, { value: null, stopReason: 'error', error: 'child failed', agentsStarted: 1 }) + expect((await first).isError).toBe(false) + expect((await second).isError).toBe(true) + expect(session.events.filter(event => event.type === 'tool-workflow/agent-start')) + .toHaveLength(1) + expect(session.events.filter(event => event.type === 'tool-workflow/run-end').map(event => event.data)) + .toEqual([ + { runId: 'run-1', stopReason: 'completed' }, + { runId: 'run-2', stopReason: 'error' }, + ]) + }) + + it('buffers synchronous member events until start returns the run identity', async () => { + const { ctx, engine, parent, session } = await setup() + engine.emitMemberDuringStart = true + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + engine.settleRun(WorkflowRunId('run-1'), { + value: null, stopReason: 'completed', agentsStarted: 1, + }) + expect((await pending).isError).toBe(false) + expect(session.events.map(event => event.type)).toEqual([ + 'tool-workflow/run-start', + 'tool-workflow/agent-start', + 'tool-workflow/agent-end', + 'tool-workflow/run-end', + ]) + }) + + it('does not record nested transport executions', async () => { + const { ctx, engine, parent, session } = await setup() + const pending = execute(ctx, { script: SCRIPT, meta: META }, { + agent: parent, + parent: Symbol('outer') as ToolExecutionToken, + }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 }) + expect((await pending).isError).toBe(false) + expect(session.events).toEqual([]) + }) + + it.each([ + 'tool-workflow/run-start', + 'tool-workflow/agent-start', + 'tool-workflow/agent-end', + 'tool-workflow/run-end', + ] as const)('isolates a first append failure at %s and preserves a valid prefix', async (failedType) => { + const { ctx, engine, parent, session } = await setup() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + const append = session.append.bind(session) + session.append = ((type: Parameters[0], data: never) => { + if (type === failedType) throw new Error(`injected ${failedType} failure`) + return append(type, data) + }) as Session['append'] + + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + const runId = WorkflowRunId('run-1') + engine.agentStart(runId, { + seq: 1, label: 'member', childId: SessionId('child-1'), + }) + engine.agentEnd(runId, { + seq: 1, label: 'member', childId: SessionId('child-1'), outcome: 'completed', + }) + engine.settleRun(runId, { value: null, stopReason: 'completed', agentsStarted: 1 }) + expect((await pending).isError).toBe(false) + expect(engine.disposed).toBe(1) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain(failedType) + const types = session.events.map(event => event.type) + const expectedPrefixes = { + 'tool-workflow/run-start': [], + 'tool-workflow/agent-start': ['tool-workflow/run-start'], + 'tool-workflow/agent-end': ['tool-workflow/run-start', 'tool-workflow/agent-start'], + 'tool-workflow/run-end': [ + 'tool-workflow/run-start', 'tool-workflow/agent-start', 'tool-workflow/agent-end', + ], + } as const + expect(types).toEqual(expectedPrefixes[failedType]) + }) + + it('contains an append failure whose thrown value cannot be rendered', async () => { + const { ctx, engine, parent, session } = await setup() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + session.append = () => { + throw { toString: () => { throw new Error('coercion trap') } } + } + const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) + engine.settleRun(WorkflowRunId('run-1'), { + value: null, stopReason: 'completed', agentsStarted: 0, + }) + expect((await pending).isError).toBe(false) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('[unrenderable thrown value]') + }) + it('maps a non-completed stop reason to an isError result (and still disposes)', async () => { const { ctx, engine, parent } = await setup() const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) @@ -251,7 +453,8 @@ describe('dsh-tool-workflow', () => { }) await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 }) await ctx.plugin(toolWorkflow, {}) - const parent = { id: SessionId('caller'), options: {} } as unknown as Agent + const session = Session.create(SessionId('caller')) + const parent = { id: session.id, options: {}, session } as unknown as Agent const controller = new AbortController() const pending = execute(ctx, { script: 'await new Promise(() => {})\nreturn 1', diff --git a/packages/workflow/tool-workflow/tsconfig.json b/packages/workflow/tool-workflow/tsconfig.json index c08ae597f2..1344946d35 100644 --- a/packages/workflow/tool-workflow/tsconfig.json +++ b/packages/workflow/tool-workflow/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/session" + }, { "path": "../../llm/llm" }, diff --git a/packages/workflow/workflow/README.i18n.yaml b/packages/workflow/workflow/README.i18n.yaml index e56067bf2d..4650b30acd 100644 --- a/packages/workflow/workflow/README.i18n.yaml +++ b/packages/workflow/workflow/README.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 packages/workflow/workflow/README.md -README.md: 0de661423206cc71eb4669bc8ddb2419202bcb4a -README.zh.md: 62abd00c013d054f4111a2db2ce72c58d3514087 +README.md: f1b101159e656d7d76812c95c020fe6b3f48115e +README.zh.md: 6d85c3b7e847b8c6176d4c1938805c22c543678b diff --git a/packages/workflow/workflow/README.md b/packages/workflow/workflow/README.md index 0de6614232..f1b101159e 100644 --- a/packages/workflow/workflow/README.md +++ b/packages/workflow/workflow/README.md @@ -6,6 +6,8 @@ The workflow seam (`ctx.workflows`) executes a model-written orchestration scrip `@deepseek-ai/dsh-workflow-workerthread` is the current engine and `@deepseek-ai/dsh-tool-workflow` is the model-facing consumer. A future process or sandbox engine can replace the implementation without changing the tool. +The package root is the Host face. The browser-safe `@deepseek-ai/dsh-workflow/types` subpath contains run identities, metadata, results, and observe-only lifecycle payloads without importing `Agent`, Cordis services, or Host context declarations; Host-only `WorkflowStartRequest` and `WorkflowRun` live behind the package root. + ## Service and run contract `WorkflowService.start(request): WorkflowRun` validates enough synchronously to reject a malformed meta block, unparseable script, unavailable provider route, or unsupported per-run limit before a run exists. Once returned, `WorkflowRun.result` never rejects: execution failures resolve with `stopReason: 'error'`, and cancellation resolves with `cancelled` within the engine's bounded grace. diff --git a/packages/workflow/workflow/README.zh.md b/packages/workflow/workflow/README.zh.md index 62abd00c01..6d85c3b7e8 100644 --- a/packages/workflow/workflow/README.zh.md +++ b/packages/workflow/workflow/README.zh.md @@ -6,6 +6,8 @@ `@deepseek-ai/dsh-workflow-workerthread` 是当前引擎,`@deepseek-ai/dsh-tool-workflow` 是面向模型的消费方。未来的进程或沙箱引擎可以替换实现,而无需更改工具。 +包根是 Host face。浏览器安全的 `@deepseek-ai/dsh-workflow/types` 子路径包含运行身份、元数据、结果和仅供观察的生命周期 payload,不导入 `Agent`、Cordis service 或 Host Context 声明;Host 专用的 `WorkflowStartRequest` 与 `WorkflowRun` 只从包根提供。 + ## 服务与运行约定 `WorkflowService.start(request): WorkflowRun` 会同步完成足够多的校验,在运行创建前拒绝格式错误的 meta 块、无法解析的脚本、不可用的提供方路由或不受支持的单次运行限制。返回后,`WorkflowRun.result` 绝不拒绝:执行失败以 `stopReason: 'error'` 兑现,取消则在引擎有限的宽限时间内以 `cancelled` 兑现。 diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 53ef7e6f6e..c916add4fe 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 526da15ad8..e7ad0d38e1 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -10,10 +10,9 @@ import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResultInfo, - WorkflowRun, WorkflowRunInfo, - WorkflowStartRequest, } from './types.ts' +import type { WorkflowRun, WorkflowStartRequest } from './runtime-types.ts' export { WorkflowRunId } from './types.ts' export type { @@ -24,11 +23,10 @@ export type { WorkflowPhase, WorkflowResult, WorkflowResultInfo, - WorkflowRun, WorkflowRunInfo, - WorkflowStartRequest, WorkflowStopReason, } from './types.ts' +export type { WorkflowRun, WorkflowStartRequest } from './runtime-types.ts' declare module 'cordis' { interface Context { diff --git a/packages/workflow/workflow/src/runtime-types.ts b/packages/workflow/workflow/src/runtime-types.ts new file mode 100644 index 0000000000..2e3525f9c3 --- /dev/null +++ b/packages/workflow/workflow/src/runtime-types.ts @@ -0,0 +1,49 @@ +/** + * Host-only workflow request and live-run handles. The browser-safe durable + * vocabulary remains in `./types` so Client programs never import Agent or + * host Cordis context declarations. + * + * @module @deepseek-ai/dsh-workflow + */ + +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { + WorkflowMeta, WorkflowResult, WorkflowRunId, +} from './types.ts' + +/** + * What a caller asks for when starting a workflow run. `meta` and `args` are + * plain JSON data by the seam contract. `parent` is required because every + * `agent()` spawned by the script is attributed to that live Agent. + */ +export interface WorkflowStartRequest { + /** The plain-JS script body (top-level await allowed; ends with `return `). */ + script: string + /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ + meta: WorkflowMeta + /** Optional input exposed verbatim to the script as the `args` global. */ + args?: unknown + /** Optional engine-wide child-provider override for this run. */ + subagentProvider?: string + /** Optional per-run total-child ceiling. */ + maxTotalAgents?: number + /** The agent on whose behalf the run executes (parent of every child). */ + parent: Agent + /** Cancels the run when aborted. */ + signal?: AbortSignal +} + +/** + * Holder-owned live workflow. `result` never rejects; consumers may cancel + * and must call idempotent `dispose()` to await script and child quiescence. + */ +export interface WorkflowRun { + readonly id: WorkflowRunId + /** The validated meta block available before the script body runs. */ + readonly meta: WorkflowMeta + readonly result: Promise + /** Cancel the run and its children. */ + cancel(reason?: string): void + /** Cancel if needed and await bounded settlement and cleanup. */ + dispose(): Promise +} diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index bdf933a3f7..52a0bac785 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -7,8 +7,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Identifies one workflow run. */ export type WorkflowRunId = Branded<'WorkflowRunId'> @@ -55,38 +54,6 @@ export interface WorkflowMeta { phases?: WorkflowPhase[] } -/** - * What a caller asks for when starting a workflow run. `meta` and `args` are - * plain JSON DATA by the seam contract (the tool builds both from the model's schema-validated call; - * the engine validates `meta` against its schema and rejects loud - * before anything runs) — an engine never evaluates script text to obtain - * them. `parent` is REQUIRED — every `agent()` the script spawns is - * attributed to it (cwd, lineage, depth flow through the subagent seam). - */ -export interface WorkflowStartRequest { - /** The plain-JS script body (top-level await allowed; ends with `return `). */ - script: string - /** The workflow's identity fields as plain JSON data, validated by the engine. */ - meta: WorkflowMeta - /** Optional input exposed verbatim to the script as the `args` global. */ - args?: unknown - /** - * Optional engine-wide child-provider override for this run. The workflow - * script cannot observe or replace it; omission uses the engine's configured - * provider. - */ - subagentProvider?: string - /** - * Optional per-run total-child ceiling. Implementations reject values above - * their deployment ceiling before publishing the run. - */ - maxTotalAgents?: number - /** The agent on whose behalf the run executes (parent of every child). */ - parent: Agent - /** Cancels the run when aborted (the tool's `exec.signal`). */ - signal?: AbortSignal -} - /** * Why a run settled. CLOSED union (engine-owned, consumers may exhaust): * `completed` = the script ran to its final `return`; `cancelled` = the run @@ -96,7 +63,7 @@ export interface WorkflowStartRequest { export type WorkflowStopReason = 'completed' | 'cancelled' | 'error' /** - * The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is + * The outcome resolved by a live workflow run. `value` is * the script's materialized return value (plain host-realm JSON data; `null` * when the script returned `undefined`) — meaningful only for `completed`. * A non-`completed` reason carries the failure in `error`; the consumer maps @@ -119,23 +86,6 @@ export interface WorkflowResult { agentsStarted: number } -/** - * Holder-owned live workflow. `result` never rejects and settles within the - * engine's cancellation grace; failures resolve through `stopReason`. Consumers - * may cancel and must call idempotent `dispose()` on every path to await bounded - * script settlement and child quiescence. - */ -export interface WorkflowRun { - readonly id: WorkflowRunId - /** The validated meta block (available before the body runs). */ - readonly meta: WorkflowMeta - readonly result: Promise - /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */ - cancel(reason?: string): void - /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ - dispose(): Promise -} - /** Identifying detail for a run, carried by every `workflow/*` event as borrowed immutable data, never the live run. */ export interface WorkflowRunInfo { /** The run's id. */ diff --git a/packages/workflow/workflow/tsconfig.json b/packages/workflow/workflow/tsconfig.json index 76ad9f725a..11a71a280b 100644 --- a/packages/workflow/workflow/tsconfig.json +++ b/packages/workflow/workflow/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/session" + }, { "path": "../../util/brand" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4912b264d7..905b1db792 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1529,6 +1529,9 @@ importers: '@deepseek-ai/dsh-client-ui-trajectory': specifier: workspace:^ version: link:../../client/ui-trajectory + '@deepseek-ai/dsh-client-ui-workflow-run': + specifier: workspace:^ + version: link:../../client/ui-workflow-run '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../../client/ui-workspace @@ -2758,6 +2761,49 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-workflow-run: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../workflow/tool-workflow + '@deepseek-ai/dsh-workflow': + specifier: workspace:^ + version: link:../../workflow/workflow + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/ui-workspace: dependencies: clsx: diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1a9d998029..36872ca1a3 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1278,7 +1278,7 @@ { "doc": "docs/subsystems/workflow.md", "symbol": "WorkflowStartRequest", - "source": "packages/workflow/workflow/src/types.ts" + "source": "packages/workflow/workflow/src/runtime-types.ts" }, { "doc": "docs/subsystems/workflow.md", @@ -1293,7 +1293,7 @@ { "doc": "docs/subsystems/workflow.md", "symbol": "WorkflowRun", - "source": "packages/workflow/workflow/src/types.ts" + "source": "packages/workflow/workflow/src/runtime-types.ts" }, { "doc": "docs/subsystems/lsp.md", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0a202e6142..15a61aa342 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -72,6 +72,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, 'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 0523b378d9..b99e2d7d73 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -69,6 +69,8 @@ "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"], "@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"], + "@deepseek-ai/dsh-workflow/types": ["./packages/workflow/workflow/src/types.ts"], + "@deepseek-ai/dsh-tool-workflow/types": ["./packages/workflow/tool-workflow/src/types.ts"], "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], @@ -169,6 +171,7 @@ "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], "@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"], "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], + "@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"], "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 632f6a84a7..f5b2d235c9 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -61,6 +61,7 @@ { "path": "./packages/client/ui-conversation" }, { "path": "./packages/client/ui-tool" }, { "path": "./packages/client/ui-deliverables" }, + { "path": "./packages/client/ui-workflow-run" }, { "path": "./packages/client/ui-workspace" }, { "path": "./packages/client/ui-slash" }, { "path": "./packages/client/ui-command" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index d9bf1c29e4..a52b1964b9 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -70,6 +70,7 @@ "apps/web/tests/composer-tab-geometry.e2e.ts", "apps/web/tests/complex-history.perf.ts", "apps/web/tests/pwsh-terminal.e2e.ts", + "apps/web/tests/workflow-run.e2e.ts", "apps/web/stress-tests/reasoning-chunks.stress.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", From 4eb0a52840df227b5541c7beb5edb64495585ec8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 19:25:21 +0800 Subject: [PATCH 02/25] fix(workflow): close review and snapshot gaps --- .../snapshots/workflow-run/ui.expected.md | 23 --- apps/web/tests/workflow-run.e2e.ts | 17 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/workflow.i18n.yaml | 4 +- docs/subsystems/workflow.md | 2 +- docs/subsystems/workflow.zh.md | 2 +- .../advanced-toolchain/session.jsonl | 48 +++--- .../snapshots/workflow-run/session.jsonl | 34 ++-- .../advanced-toolchain/session.jsonl | 66 ++++---- .../stream-json.expected.jsonl | 46 +++--- .../src/client/WorkflowRunPanel.module.css | 8 +- .../src/client/WorkflowRunPanel.tsx | 120 +++++++------- .../ui-workflow-run/src/client/index.ts | 6 - .../src/client/workflow-definition.ts | 22 +-- .../tests/workflow-run.spec.tsx | 34 ++-- packages/workflow/tool-workflow/src/index.ts | 146 +++++++----------- .../workflow/tool-workflow/src/invariant.ts | 40 +++-- .../tool-workflow/tests/tool-workflow.spec.ts | 24 --- 20 files changed, 300 insertions(+), 350 deletions(-) diff --git a/apps/web/tests/snapshots/workflow-run/ui.expected.md b/apps/web/tests/snapshots/workflow-run/ui.expected.md index 7a2e1cfd13..297aad1b70 100644 --- a/apps/web/tests/snapshots/workflow-run/ui.expected.md +++ b/apps/web/tests/snapshots/workflow-run/ui.expected.md @@ -1,14 +1,3 @@ -- banner: - - navigation "Session hierarchy": - - button "Use the workflow tool exactly" [disabled] - - button "1 subagent": - - text: 1 subagent - - img - - img - - text: 标准模式 - - tablist: - - tab "Chat" [selected] - - tab "Trajectory" - text: "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): phase('Run') const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') return { reply } After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool. {{clock}}" - button "Copy": - img @@ -41,15 +30,3 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- button "Back to bottom": - - img -- textbox "Message the agent" -- button "Commands": - - img -- 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img -- button "3% of context used" -- button "Send message" [disabled] -- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 47% Input 6.6K tok · Output 227 tok diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index cacefa75a4..eafb78223f 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -93,8 +93,16 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const label = element.querySelector('[data-member-label]') const labelWrap = element.querySelector('[data-member-label-wrap]') const status = element.querySelector('[data-member-status-text]') - const runHeader = element.querySelector('[data-run-header]') - const phaseHeader = element.querySelector('[data-phase-header]') + const disclosures = element.querySelectorAll('[data-disclosure-row]') + const runHeader = disclosures[0] + const phaseHeader = disclosures[1] + const phaseTitle = phaseHeader?.children.item(1) as HTMLElement | null + const phaseStatus = element.querySelector('[data-phase-status-text]') + const originalPhaseTitle = phaseTitle?.textContent ?? '' + if (phaseTitle !== null) phaseTitle.textContent = 'A phase name long enough to require ellipsis in the narrow layout' + const phaseTitleRight = phaseTitle?.getBoundingClientRect().right ?? 0 + const phaseStatusLeft = phaseStatus?.getBoundingClientRect().left ?? 0 + if (phaseTitle !== null) phaseTitle.textContent = originalPhaseTitle return { clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, @@ -105,6 +113,8 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = statusFontSize: status === null ? '' : getComputedStyle(status).fontSize, runHeight: runHeader?.getBoundingClientRect().height ?? 0, phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0, + phaseTitleRight, + phaseStatusLeft, } }) expect(darkNarrow.clientWidth).toBe(356) @@ -116,6 +126,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = expect(darkNarrow.statusFontSize).toBe('13px') expect(darkNarrow.runHeight).toBe(32) expect(darkNarrow.phaseHeight).toBe(32) + expect(darkNarrow.phaseTitleRight).toBeLessThanOrEqual(darkNarrow.phaseStatusLeft) await page.locator('[data-workflow-run]').evaluate((element) => { (element as HTMLElement).style.removeProperty('width') document.body.removeAttribute('data-ds-dark-theme') @@ -158,7 +169,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = await page.getByText(CHILD_PROMPT, { exact: false }).waitFor() expect(await page.getByRole('button', { name: /^Open Reply with exactly the word/ }).count()).toBe(0) - const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + const snapshot = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }, 60_000) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9a0d47093e..5d08bb4a0c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: d9b70eb15865b45d0d8251789d6d661cd9747024 -config-catalog.zh.md: 4974a7e53c60507c2dced9a93cb5e2a2ba0ed850 +config-catalog.md: 7ae543267733cbe27541b1fca5599a2a09d6462d +config-catalog.zh.md: 5ed7b1fc2cf6576496ec144d1fdccf85ce646717 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d9b70eb158..7ae5432677 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2412,7 +2412,7 @@ export interface Config { } ``` -Source: [`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) +Source: [`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4974a7e53c..5ed7b1fc2c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2413,7 +2413,7 @@ export interface Config { } ``` -来源:[`packages/workflow/tool-workflow/src/index.ts:34`](../packages/workflow/tool-workflow/src/index.ts) +来源:[`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts) ## `@deepseek-ai/dsh-tools` diff --git a/docs/subsystems/workflow.i18n.yaml b/docs/subsystems/workflow.i18n.yaml index 3100aaeddc..4061410c14 100644 --- a/docs/subsystems/workflow.i18n.yaml +++ b/docs/subsystems/workflow.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 docs/subsystems/workflow.md -workflow.md: b651a5459d4ff8c71de223ca2b51dca997ab86bf -workflow.zh.md: 0fd32675c8612dfeee1dbce7cd8e9977bbe330ef +workflow.md: 3c7cc39feb8493b9ace11ae10c031a34a3942aee +workflow.zh.md: c945a339c91402004a790ebc1ce7ffd5f8921ef6 diff --git a/docs/subsystems/workflow.md b/docs/subsystems/workflow.md index b651a5459d..3c7cc39feb 100644 --- a/docs/subsystems/workflow.md +++ b/docs/subsystems/workflow.md @@ -125,7 +125,7 @@ The top-level `dsh-tool-workflow` consumer projects display facts into its calli `dsh-tool-workflow/invariant` validates the same protocol before live commit and when a Session is loaded: one start per run, positive unique member sequences, paired member endings, no run ending with open members, and no updates after the run ending. A missing member ending or run ending at the log tail is valid interruption evidence rather than corruption. -`dsh-client-ui-workflow-run` folds the four events through the Conversation Node engine into one `workflow-run` Chat node anchored at the run-start sequence, after the original workflow tool node. Phase groups come only from actual member starts and preserve exact strings, including the distinction between an omitted phase and `''`. Closed Locations turn missing terminal facts into interrupted presentation. The 32-pixel run row uses module-platform background, persistent chevrons, and inline dot plus status text; 32-pixel phase rows keep title and count in the main area and precise aggregate status in a fixed tail without another dot; members use a 16-pixel dot slot and fixed 64-pixel lifecycle column. Underlined names alone mark navigation while the member and current list both prove a running same-parent local subagent. +`dsh-client-ui-workflow-run` folds the four events through the Conversation Node engine into one `workflow-run` Chat node anchored at the run-start sequence, after the original workflow tool node. Phase groups come only from actual member starts and preserve exact strings, including the distinction between an omitted phase and `''`. Closed Locations turn missing terminal facts into interrupted presentation. The [UI package README](../../packages/client/ui-workflow-run/README.md) owns disclosure, status, and same-parent local navigation behavior. diff --git a/docs/subsystems/workflow.zh.md b/docs/subsystems/workflow.zh.md index 0fd32675c8..c945a339c9 100644 --- a/docs/subsystems/workflow.zh.md +++ b/docs/subsystems/workflow.zh.md @@ -125,7 +125,7 @@ interface WorkflowRun { `dsh-tool-workflow/invariant` 会在实时提交前和 Session 加载时校验同一协议:每个运行只有一个 start,成员序号为正且唯一,成员 end 必须配对,仍有开放成员时不能结束运行,运行结束后不能继续更新。日志尾部缺少成员 end 或 run end 是有效的中断证据,不是损坏。 -`dsh-client-ui-workflow-run` 通过 Conversation Node 引擎把四类事件折叠为一个 `workflow-run` Chat 节点,以 run-start 序号锚定在原工作流工具节点之后。阶段组只来自真正开始过的成员,并保留精确字符串,包括字段缺省与 `''` 的区别。Location 关闭时,缺失终点会显示为已中断。32 像素运行行使用 module-platform 背景、常驻 chevron 与内联状态点加文字;32 像素阶段行在主区显示标题和计数,在固定尾部精确显示聚合状态且不重复状态点;成员使用 16 像素状态点槽和固定 64 像素生命周期列。只有成员状态与当前列表同时证明它是同父级、仍运行的本地 subagent 时,带下划线名称才标记普通 Session 导航。 +`dsh-client-ui-workflow-run` 通过 Conversation Node 引擎把四类事件折叠为一个 `workflow-run` Chat 节点,以 run-start 序号锚定在原工作流工具节点之后。阶段组只来自真正开始过的成员,并保留精确字符串,包括字段缺省与 `''` 的区别。Location 关闭时,缺失终点会显示为已中断。[界面包 README](../../packages/client/ui-workflow-run/README.md)负责定义 disclosure、状态与同父本地导航行为。 diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index d6935b6c98..b92c50efa5 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821417919,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498801761,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"6e45782a-31be-4ba7-8c4a-7411a2027e36"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9f38e2b8-1d4e-4c90-8896-00aa42307ea7"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f66cc92b-b90c-4aeb-9568-7463d5eeede9"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730458430,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498801765,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730458431,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -47,24 +47,28 @@ {"type":"assistant/chunk","seq":45,"time":1785730458577,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":46,"time":1785730458577,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ebeca5c6-68ae-43b3-87c3-c48fdfe416c8"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} {"type":"tool/call","seq":47,"time":1785730458577,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}} -{"type":"tool/result","seq":48,"time":1785730458711,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"f892f17e-1e93-4f4b-9e9e-15116593b6fc"}},"sourceEventSeqs":[47],"surfaceOp":"append"} -{"type":"step/end","seq":49,"time":1785730458711,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":50,"time":1785730458723,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":53,"time":1785036891795,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":54,"time":1785498802087,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":55,"time":1785730458728,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":56,"time":1785730458728,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1291ce3c-e568-4f0d-a95a-5157b8b2cc75"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"} -{"type":"tool/call","seq":57,"time":1785730458728,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":58,"time":1785730458735,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"b3634221-2358-4e82-aac5-e37f0a115023"}},"sourceEventSeqs":[57],"surfaceOp":"append"} -{"type":"step/end","seq":59,"time":1785730458735,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":60,"time":1785730458747,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":61,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":62,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} -{"type":"assistant/chunk","seq":63,"time":1785036891804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} -{"type":"assistant/chunk","seq":64,"time":1785498802107,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":65,"time":1785730458751,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":66,"time":1785730458751,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a32b89ce-13ed-48ba-a7f9-24144b94ec56"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[61,62,63,64,65],"surfaceOp":"append"} -{"type":"step/end","seq":67,"time":1785730458751,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":68,"time":1785730458751,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"tool-workflow/run-start","seq":48,"time":1786359248404,"data":{"runId":"2f6d6a6e-6d76-4a8a-8677-6671366645dc","name":"advanced-acp-snapshot"}} +{"type":"tool-workflow/agent-start","seq":49,"time":1786359248518,"data":{"runId":"2f6d6a6e-6d76-4a8a-8677-6671366645dc","seq":1,"label":"workflow-child","phase":"Delegate","childId":"33333333-3333-4333-8333-333333333333"}} +{"type":"tool-workflow/agent-end","seq":50,"time":1786359248542,"data":{"runId":"2f6d6a6e-6d76-4a8a-8677-6671366645dc","seq":1,"outcome":"completed"}} +{"type":"tool-workflow/run-end","seq":51,"time":1786359248543,"data":{"runId":"2f6d6a6e-6d76-4a8a-8677-6671366645dc","stopReason":"completed"}} +{"type":"tool/result","seq":52,"time":1786359248543,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"f892f17e-1e93-4f4b-9e9e-15116593b6fc"}},"sourceEventSeqs":[47],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1786359248543,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":54,"time":1786359248550,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":55,"time":1785730458728,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":56,"time":1786359248554,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":57,"time":1786359248554,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":58,"time":1786359248554,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":59,"time":1786359248554,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":60,"time":1786359248554,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1291ce3c-e568-4f0d-a95a-5157b8b2cc75"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"tool/call","seq":61,"time":1786359248554,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":62,"time":1786359248558,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"b3634221-2358-4e82-aac5-e37f0a115023"}},"sourceEventSeqs":[61],"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":1786359248558,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":64,"time":1786359248564,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":65,"time":1785730458751,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":66,"time":1786359248568,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} +{"type":"assistant/chunk","seq":67,"time":1786359248568,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} +{"type":"assistant/chunk","seq":68,"time":1786359248568,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":69,"time":1786359248568,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":70,"time":1786359248568,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a32b89ce-13ed-48ba-a7f9-24144b94ec56"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"} +{"type":"step/end","seq":71,"time":1786359248568,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":72,"time":1786359248568,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 6ee104dd0c..16d284eb09 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821416248,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783600631839,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498800152,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"5188a9c7-d3ca-4679-b8df-1443e0a0a4df"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730457160,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"1c92c213-1d4f-45ad-be50-161f26a23e65"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730457160,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"7b864c39-41fc-4bfb-809a-0dd9f1dc4383"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730457160,"data":{"title":"Use the workflow tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498800153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730457161,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -18,17 +18,21 @@ {"type":"assistant/chunk","seq":163,"time":1785730457174,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":164,"time":1785730457174,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9a15ecb9-11ce-4d1b-9a0a-07cc388dc0e0"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[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,156,157,158,159,160,161,162,163],"surfaceOp":"append"} {"type":"tool/call","seq":165,"time":1785730457174,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}} -{"type":"tool/result","seq":166,"time":1785730457320,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449"},"content":[{"type":"tool-result","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"a3ca6fd6-3d4c-4ad2-a67c-fc9479ef4f15"}},"sourceEventSeqs":[165],"surfaceOp":"append"} -{"type":"step/end","seq":167,"time":1785730457320,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":168,"time":1785730457334,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":169,"time":1783600640134,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":170,"time0":1783600640162,"data":{"turn":1,"step":2,"index":0,"dt":[33,667,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," workflow"," returned"," successfully"," with"," the"," reply"," \"","WF","_CH","ILD","_OK","\"."," Now"," I"," need"," to"," reply"," with"," exactly"," \"","WORK","FL","OW","_D","ONE","\""," and"," stop","."]}} -{"type":"assistant/chunk","seq":200,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":201,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0],"texts":["WORK","FL","OW","_D","ONE"]}} -{"type":"assistant/chunk","seq":206,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} -{"type":"assistant/chunk","seq":207,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} -{"type":"assistant/chunk","seq":208,"time":1785498800365,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} -{"type":"assistant/chunk","seq":209,"time":1785730457339,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":210,"time":1785730457339,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"265fc6fa-19e0-4df9-b4ea-f38141ba4efa"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209],"surfaceOp":"append"} -{"type":"step/end","seq":211,"time":1785730457339,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":212,"time":1785730457339,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"tool-workflow/run-start","seq":166,"time":1786359246611,"data":{"runId":"632cc7d7-38d4-45ba-b6c5-55e5784b2501","name":"snapshot-flow"}} +{"type":"tool-workflow/agent-start","seq":167,"time":1786359246721,"data":{"runId":"632cc7d7-38d4-45ba-b6c5-55e5784b2501","seq":1,"label":"Reply with exactly the word WF_CHILD_OK and not…","phase":"Run","childId":"583a4db2-3350-436c-b4a5-5615fd159052"}} +{"type":"tool-workflow/agent-end","seq":168,"time":1786359246743,"data":{"runId":"632cc7d7-38d4-45ba-b6c5-55e5784b2501","seq":1,"outcome":"completed"}} +{"type":"tool-workflow/run-end","seq":169,"time":1786359246745,"data":{"runId":"632cc7d7-38d4-45ba-b6c5-55e5784b2501","stopReason":"completed"}} +{"type":"tool/result","seq":170,"time":1786359246745,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449"},"content":[{"type":"tool-result","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"a3ca6fd6-3d4c-4ad2-a67c-fc9479ef4f15"}},"sourceEventSeqs":[165],"surfaceOp":"append"} +{"type":"step/end","seq":171,"time":1786359246746,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":172,"time":1786359246751,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":173,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":174,"time0":1783600640862,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," workflow"," returned"," successfully"," with"," the"," reply"," \"","WF","_CH","ILD","_OK","\"."," Now"," I"," need"," to"," reply"," with"," exactly"," \"","WORK","FL","OW","_D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":205,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,1898159500,231656974],"texts":["WORK","FL","OW","_D","ONE"]}} +{"type":"assistant/chunk","seq":210,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":211,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} +{"type":"assistant/chunk","seq":212,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":213,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":214,"time":1786359246756,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"265fc6fa-19e0-4df9-b4ea-f38141ba4efa"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213],"surfaceOp":"append"} +{"type":"step/end","seq":215,"time":1786359246757,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":216,"time":1786359246757,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 646110b6d9..b64afd808e 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"63f46c0a-1c99-4b19-b097-fcb2d0d12357"}]}} {"type":"turn/start","seq":1,"time":1785821454304,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454304,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"63f46c0a-1c99-4b19-b097-fcb2d0d12357"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e65c0ebe-8e3d-44c0-833f-68efcbc0acb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f468e717-7654-4020-9fe2-53300ff16763"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"abb8ecee-cb03-4a66-9477-38a52458ab05"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"4bad4fa0-ca5e-4062-887c-b93f31bc89ba"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501413,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1785730501423,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,11 +22,11 @@ {"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4a157153-f4e0-4417-a595-e3fdb848ee72"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} {"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"e66e0537-ae11-4783-bf67-1eab7210bd11"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -34,9 +34,9 @@ {"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba4958e9-231c-437f-a2fc-7a13f392d3ba"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c5a9eee-b5ae-4d17-994f-d5ce5d57c3b3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} {"type":"tool/call","seq":36,"time":1785730501484,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"b9ebb37d-e565-4882-95b0-5343da1d68d8"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"c1f65bfd-dc5c-4b11-b4d0-1e45628168aa"}},"sourceEventSeqs":[36],"surfaceOp":"append"} {"type":"step/end","seq":38,"time":1785730501508,"data":{"turn":1,"step":3}} {"type":"step/start","seq":39,"time":1785730501521,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -44,26 +44,30 @@ {"type":"assistant/chunk","seq":42,"time":1785037378946,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4757f4b9-9bde-488b-a54a-1bdea55dd15f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"162f6c74-332c-4819-b498-4e2000a71895"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} {"type":"tool/call","seq":46,"time":1785730501522,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"35baa460-54ff-4fa1-ba9d-66b6661f84e9"}},"sourceEventSeqs":[46],"surfaceOp":"append"} -{"type":"step/end","seq":48,"time":1785730501648,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":49,"time":1785730501660,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":53,"time":1785498584085,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"739166e2-ed48-4df2-a9a5-207f34058030"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} -{"type":"tool/call","seq":56,"time":1785730501661,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"98b05c06-cb77-41a9-8310-324bc72fc7a0"}},"sourceEventSeqs":[56],"surfaceOp":"append"} -{"type":"step/end","seq":58,"time":1785730501668,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":59,"time":1785730501678,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":61,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}} -{"type":"assistant/chunk","seq":62,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} -{"type":"assistant/chunk","seq":63,"time":1785498584102,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a4ca8f2-92c1-4dbc-beb8-923b8791c298"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} -{"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"tool-workflow/run-start","seq":47,"time":1786359174028,"data":{"runId":"668432bb-f01c-41e7-841e-30d8deab7b55","name":"advanced-headless-snapshot"}} +{"type":"tool-workflow/agent-start","seq":48,"time":1786359174210,"data":{"runId":"668432bb-f01c-41e7-841e-30d8deab7b55","seq":1,"label":"workflow-child","phase":"Delegate","childId":"33333333-3333-4333-8333-333333333333"}} +{"type":"tool-workflow/agent-end","seq":49,"time":1786359174230,"data":{"runId":"668432bb-f01c-41e7-841e-30d8deab7b55","seq":1,"outcome":"completed"}} +{"type":"tool-workflow/run-end","seq":50,"time":1786359174232,"data":{"runId":"668432bb-f01c-41e7-841e-30d8deab7b55","stopReason":"completed"}} +{"type":"tool/result","seq":51,"time":1786359174232,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"630f5c50-936a-4cfd-b659-69eeba6f9d3f"}},"sourceEventSeqs":[46],"surfaceOp":"append"} +{"type":"step/end","seq":52,"time":1786359174233,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":53,"time":1786359174239,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":55,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":56,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":57,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":58,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":59,"time":1786359174239,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"97e67ea7-7d8d-4ab9-8bcd-0b7fab0216a2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[54,55,56,57,58],"surfaceOp":"append"} +{"type":"tool/call","seq":60,"time":1786359174239,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":61,"time":1786359174243,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"0a466846-c6c2-475c-a7bc-f201bcfdd28b"}},"sourceEventSeqs":[60],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1786359174243,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":63,"time":1786359174248,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":65,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}} +{"type":"assistant/chunk","seq":66,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} +{"type":"assistant/chunk","seq":67,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":68,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":69,"time":1786359174249,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e8d83a6c-28f1-4ef1-9d90-a729dd2efe97"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[64,65,66,67,68],"surfaceOp":"append"} +{"type":"step/end","seq":70,"time":1786359174249,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":71,"time":1786359174249,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index 817ee1e1a2..469c156969 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -45,25 +45,29 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":46,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":47,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[46],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":48,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":49,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":56,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":57,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[56],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":58,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":59,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":65,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":66,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":67,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/run-start","seq":47,"time":0,"data":{"runId":"{{sessionId}}","name":"advanced-headless-snapshot"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/agent-start","seq":48,"time":0,"data":{"runId":"{{sessionId}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{sessionId}}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/agent-end","seq":49,"time":0,"data":{"runId":"{{sessionId}}","seq":1,"outcome":"completed"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/run-end","seq":50,"time":0,"data":{"runId":"{{sessionId}}","stopReason":"completed"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":51,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[46],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":52,"time":0,"data":{"turn":1,"step":4}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":53,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":59,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[54,55,56,57,58],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":60,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":61,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[60],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":62,"time":0,"data":{"turn":1,"step":5}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":63,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":69,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[64,65,66,67,68],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":70,"time":0,"data":{"turn":1,"step":6}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":71,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} {"type":"result","sessionId":"{{sessionId}}","output":"ADVANCED_HEADLESS_OK","usage":{"inputTokens":18,"outputTokens":18}} diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css index 0f069ac77b..77145ee06a 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.module.css @@ -29,6 +29,7 @@ height: 16px; align-items: center; justify-content: center; + margin-right: 0; color: var(--dsw-alias-label-tertiary); } @@ -93,14 +94,19 @@ height: 16px; align-items: center; justify-content: center; + margin-right: 0; color: var(--dsw-alias-label-tertiary); } .phaseTitle { - flex: none; + overflow: hidden; + flex: 0 1 auto; + min-width: 0; + max-width: 42%; color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 24px; + text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index 313bb06c97..8e48ffb4be 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -1,6 +1,6 @@ -import { useMemo, useState, type KeyboardEvent } from 'react' +import { useMemo, useState } from 'react' import { - IconChevronDownOutline14, IconChevronRightOutline14, StateDot, type StateDotState, + DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -71,12 +71,6 @@ function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: Workfl return visible.map(status => statusCount(status, count(status), t)).join(' · ') } -function handleDisclosureKey(event: KeyboardEvent, onToggle: () => void): void { - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - onToggle() -} - function RunHeader({ count, name, onToggle, open, status, t }: { readonly count: number readonly name: string @@ -86,27 +80,29 @@ function RunHeader({ count, name, onToggle, open, status, t }: { readonly t: WorkflowRunPanelProps['t'] }) { return ( -
{ handleDisclosureKey(event, onToggle) }} - > - - {open ? : } - - {t('run.title', { name })} - - {t('run.members', { count })} - - - {t(STATUS_KEYS[status])} - -
+ } + title={t('run.title', { name })} + open={open} + expandable + onToggle={onToggle} + expandOnRowClick + previewChevron={false} + keepContentWhenOpen + rowClassName={css.runHeader} + leadingClassName={css.runLeading} + titleClassName={css.runTitle} + collapsedContent={( + <> + + {t('run.members', { count })} + + + {t(STATUS_KEYS[status])} + + + )} + /> ) } @@ -149,38 +145,39 @@ function PhaseSection({ phase, navigable, openSession, t }: { const [open, setOpen] = useState(false) const toggle = (): void => { setOpen(value => !value) } return ( -
-
{ handleDisclosureKey(event, toggle) }} - > - - {open ? : } - - {readablePhase(phase.phase, t)} - - {t('run.members', { count: phase.members.length })} - {phaseStatusSummary(phase.members, t)} -
- {open && ( -
- {phase.members.map(member => ( - - ))} -
+ } + title={readablePhase(phase.phase, t)} + open={open} + expandable + onToggle={toggle} + expandOnRowClick + previewChevron={false} + keepContentWhenOpen + className={css.phase} + rowClassName={css.phaseHeader} + leadingClassName={css.phaseLeading} + titleClassName={css.phaseTitle} + collapsedContent={( + <> + + {t('run.members', { count: phase.members.length })} + {phaseStatusSummary(phase.members, t)} + )} -
+ > +
+ {phase.members.map(member => ( + + ))} +
+
) } @@ -188,6 +185,7 @@ function PhaseSection({ phase, navigable, openSession, t }: { export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) { const [open, setOpen] = useState(() => node.data.status === 'running') const sessions = useSessions(value => value) + const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0) const navigable = useMemo(() => { const ordinary = new Set(sessions.ids) const result = new Set() @@ -208,7 +206,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t return (
{ readonly outcome?: WorkflowAgentOutcome } @@ -90,14 +88,6 @@ function locationClosed(location: ConversationLocation | undefined): boolean { return location.kind === 'turn' && location.turn.status === 'closed' } -function aggregateStatus(members: readonly WorkflowRunMemberData[]): WorkflowRunStatus { - if (members.some(member => member.status === 'running')) return 'running' - if (members.some(member => member.status === 'failed')) return 'failed' - if (members.some(member => member.status === 'cancelled')) return 'cancelled' - if (members.some(member => member.status === 'interrupted')) return 'interrupted' - return 'completed' -} - function projectWorkflow( context: ConversationNodeContext, ): WorkflowRunChatData | undefined { @@ -126,7 +116,6 @@ function projectWorkflow( const projectedPhases = [...phases].map(([key, phase]) => ({ key, phase: phase.phase, - status: aggregateStatus(phase.members), members: phase.members, })) return { @@ -134,13 +123,18 @@ function projectWorkflow( status: state.stopReason === undefined ? interrupted ? 'interrupted' : 'running' : statusFromStopReason(state.stopReason), - memberCount: state.members.length, phases: projectedPhases, } } function updateAgentStart(state: WorkflowState, data: ToolWorkflowAgentStartData): WorkflowState { - return { ...state, members: [...state.members, data] } + const member: WorkflowMemberState = { + seq: data.seq, + label: data.label, + ...data.phase === undefined ? {} : { phase: data.phase }, + childId: data.childId, + } + return { ...state, members: [...state.members, member] } } function updateAgentEnd(state: WorkflowState, data: ToolWorkflowAgentEndData): WorkflowState { diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 3b7a2b3f79..8e3019df19 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -107,14 +107,13 @@ describe('workflow-run Conversation Definition', () => { expect(data).toEqual({ name: 'audit', status: 'failed', - memberCount: 2, phases: [ { - key: 'value:0:', phase: '', status: 'completed', + key: 'value:0:', phase: '', members: [{ seq: 1, label: 'first', childId: 'child-1', status: 'completed' }], }, { - key: 'missing', phase: null, status: 'failed', + key: 'missing', phase: null, members: [{ seq: 2, label: 'second', childId: 'child-2', status: 'failed' }], }, ], @@ -167,7 +166,7 @@ describe('workflow-run Conversation Definition', () => { at(4, 'tool-workflow/run-end', { runId: 'empty', stopReason: 'completed' }), ]) expect(workflowData(value)).toEqual({ - name: 'empty', status: 'completed', memberCount: 0, phases: [], + name: 'empty', status: 'completed', phases: [], }) }) @@ -187,7 +186,7 @@ describe('workflow-run Conversation Definition', () => { ]) expect(workflowData(cancelled)).toMatchObject({ status: 'cancelled', - phases: [{ phase: 'Research', status: 'cancelled', members: [{ status: 'cancelled' }, { status: 'completed' }] }], + phases: [{ phase: 'Research', members: [{ status: 'cancelled' }, { status: 'completed' }] }], }) const interruptedTurn = assembler([ @@ -254,7 +253,6 @@ function node(data: WorkflowRunChatData): WorkflowRunPanelProps['node'] { const phase = (overrides: Partial = {}): WorkflowRunChatData['phases'][number] => ({ key: 'missing', phase: null, - status: 'running', members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: 'running' }], ...overrides, }) @@ -303,7 +301,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi describe('WorkflowRunPanel', () => { it('defaults running runs open, terminal history closed, and keeps the current choice across data updates', () => { const running: WorkflowRunChatData = { - name: 'audit', status: 'running', memberCount: 1, phases: [phase()], + name: 'audit', status: 'running', phases: [phase()], } const view = render() expect(screen.getByText('未分阶段')).toBeTruthy() @@ -321,7 +319,7 @@ describe('WorkflowRunPanel', () => { it('supports root keyboard disclosure and renders a zero-member running state', () => { render() const header = screen.getByRole('button', { name: /^keyboard/ }) @@ -344,14 +342,14 @@ describe('WorkflowRunPanel', () => { cleanup() render() expect(screen.getByText('没有启动成员')).toBeTruthy() }) it('keeps phase disclosure independent and preserves empty versus absent names', () => { render( { it('covers the Figma completed, failed/cancelled, and interrupted state boards', () => { const completed: WorkflowRunChatData = { - name: 'repo-audit', status: 'completed', memberCount: 1, + name: 'repo-audit', status: 'completed', phases: [phase({ - status: 'completed', members: [{ seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }], })], } @@ -387,9 +384,8 @@ describe('WorkflowRunPanel', () => { completedView.unmount() const mixed: WorkflowRunChatData = { - name: 'repo-audit', status: 'failed', memberCount: 2, + name: 'repo-audit', status: 'failed', phases: [phase({ - status: 'failed', members: [ { seq: 1, label: 'failed', childId: 'child-1' as SessionId, status: 'failed' }, { seq: 2, label: 'cancelled', childId: 'child-2' as SessionId, status: 'cancelled' }, @@ -407,17 +403,16 @@ describe('WorkflowRunPanel', () => { mixedView.unmount() const interrupted: WorkflowRunChatData = { - name: 'repo-audit', status: 'interrupted', memberCount: 2, + name: 'repo-audit', status: 'interrupted', phases: [ phase({ - status: 'interrupted', members: [ { seq: 1, label: 'done', childId: 'child-1' as SessionId, status: 'completed' }, { seq: 2, label: 'interrupted', childId: 'child-2' as SessionId, status: 'interrupted' }, ], }), phase({ - key: 'interrupted-only', phase: 'Interrupted only', status: 'interrupted', + key: 'interrupted-only', phase: 'Interrupted only', members: [{ seq: 3, label: 'interrupted', childId: 'child-3' as SessionId, status: 'interrupted', }], @@ -433,7 +428,7 @@ describe('WorkflowRunPanel', () => { it('opens only a running ordinary-list subagent proven to have this parent', () => { const data: WorkflowRunChatData = { - name: 'audit', status: 'running', memberCount: 1, phases: [phase()], + name: 'audit', status: 'running', phases: [phase()], } const openSession = vi.fn() render() @@ -459,9 +454,8 @@ describe('WorkflowRunPanel', () => { ['member terminal', listState(), 'completed'], ] as const)('does not navigate when %s', (_name, sessions, memberStatus) => { const data: WorkflowRunChatData = { - name: 'audit', status: 'running', memberCount: 1, + name: 'audit', status: 'running', phases: [phase({ - status: memberStatus === 'running' ? 'running' : 'completed', members: [{ seq: 1, label: 'worker', childId: 'child-1' as SessionId, status: memberStatus, }], diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index b815a776c8..b479e6c9fc 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -17,8 +17,7 @@ import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEventMap } from '@deepseek-ai/dsh-session' import type { - WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun, - WorkflowRunId, WorkflowRunInfo, WorkflowStopReason, + WorkflowResult, WorkflowRun, WorkflowRunId, WorkflowStopReason, } from '@deepseek-ai/dsh-workflow' import type { ToolWorkflowAgentEndData, ToolWorkflowAgentStartData, @@ -45,14 +44,10 @@ export const Config: z = z.object({ type ResolvedConfig = Required -type BufferedWorkflowEvent = - | { readonly kind: 'agent-start'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentInfo } - | { readonly kind: 'agent-end'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentEndInfo } - interface WorkflowRecorder { - bind(run: WorkflowRun): void - finish(stopReason: WorkflowStopReason): void - dispose(): void + start(session: Session, run: WorkflowRun): void + finish(runId: WorkflowRunId, stopReason: WorkflowStopReason): void + abandon(runId: WorkflowRunId): void } interface ToolWorkflowRecordEventMap { @@ -72,84 +67,66 @@ function renderRecordingError(error: unknown): string { } /** - * Project one top-level workflow run into its parent Session without letting - * recording failure affect tool execution. Listeners are installed before - * `start()` so even a synchronous provider cannot outrun the recorder. + * Project active top-level workflow runs into their parent Sessions without + * letting recording failure affect tool execution. */ -function createWorkflowRecorder(ctx: Context, session: Session): WorkflowRecorder { - let runId: WorkflowRunId | undefined - let enabled = true - const buffered: BufferedWorkflowEvent[] = [] - // These four package-owned events are all log-only. Narrowing the generic - // append face here lets TypeScript discharge Session.append's conditional - // surface-options tuple once for the complete closed event set. - const appendRecord = session.append.bind(session) as ( - type: Type, - data: SessionEventMap[Type], - ) => void - +function createWorkflowRecorder(ctx: Context): WorkflowRecorder { + const active = new Map() const append = ( + session: Session, type: Type, data: SessionEventMap[Type], - ): void => { - if (!enabled) return + ): boolean => { + // These four package-owned events are all log-only. Narrowing the generic + // append face here discharges Session.append's conditional options tuple. + const appendRecord = session.append.bind(session) as ( + event: Event, + value: SessionEventMap[Event], + ) => void try { appendRecord(type, data) + return true } catch (error: unknown) { - enabled = false ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`) + return false } } - const record = (event: BufferedWorkflowEvent): void => { - if (runId === undefined) { - buffered.push(event) - return + ctx.on('workflow/agent-start', (info, agent) => { + const session = active.get(info.id) + if (session === undefined) return + const data: ToolWorkflowAgentStartData = { + runId: info.id, + seq: agent.seq, + label: agent.label, + ...agent.phase === undefined ? {} : { phase: agent.phase }, + childId: agent.childId, } - if (event.info.id !== runId) return - if (event.kind === 'agent-start') { - const data: ToolWorkflowAgentStartData = { - runId, - seq: event.agent.seq, - label: event.agent.label, - ...event.agent.phase === undefined ? {} : { phase: event.agent.phase }, - childId: event.agent.childId, - } - append('tool-workflow/agent-start', data) - return - } - const data: ToolWorkflowAgentEndData = { - runId, - seq: event.agent.seq, - outcome: event.agent.outcome, - } - append('tool-workflow/agent-end', data) - } - - const disposeStart = ctx.on('workflow/agent-start', (info, agent) => { - record({ kind: 'agent-start', info, agent }) + if (!append(session, 'tool-workflow/agent-start', data)) active.delete(info.id) }) - const disposeEnd = ctx.on('workflow/agent-end', (info, agent) => { - record({ kind: 'agent-end', info, agent }) + ctx.on('workflow/agent-end', (info, agent) => { + const session = active.get(info.id) + if (session === undefined) return + const data: ToolWorkflowAgentEndData = { + runId: info.id, + seq: agent.seq, + outcome: agent.outcome, + } + if (!append(session, 'tool-workflow/agent-end', data)) active.delete(info.id) }) return { - bind(run) { - runId = run.id - append('tool-workflow/run-start', { runId, name: run.meta.name }) - for (const event of buffered) record(event) - buffered.length = 0 + start(session, run) { + if (append(session, 'tool-workflow/run-start', { runId: run.id, name: run.meta.name })) { + active.set(run.id, session) + } }, - finish(stopReason) { - /* v8 ignore next -- execute binds every returned run before result settlement can call finish. */ - if (runId === undefined) return - append('tool-workflow/run-end', { runId, stopReason }) - }, - dispose() { - disposeStart() - disposeEnd() - buffered.length = 0 + finish(runId, stopReason) { + const session = active.get(runId) + if (session !== undefined) append(session, 'tool-workflow/run-end', { runId, stopReason }) + active.delete(runId) }, + abandon: (runId) => { active.delete(runId) }, } } @@ -229,6 +206,7 @@ export function apply(ctx: Context, config: Config): void { // schemastery (the exported Config schema) has already filled the defaulted // fields; the assertion records that resolution, not a hidden fallback. const { toolName, maxResultChars } = config as ResolvedConfig + const recorder = createWorkflowRecorder(ctx) // Usage policy ships with the tool (the master convention: tool guidance // lives in tool plugins as prompt sections, not in the deployment persona). ctx.systemPrompt.section({ @@ -303,23 +281,15 @@ export function apply(ctx: Context, config: Config): void { // Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw // synchronously here and become isError results via the registry — the // model sees the violation list and can correct the call. - const recorder = exec.parent === undefined - ? createWorkflowRecorder(ctx, parent.session) - : undefined - let run: WorkflowRun - try { - run = ctx.workflows.start({ - script: args.script, - meta: args.meta, - ...args.args !== undefined ? { args: args.args } : {}, - parent, - signal: exec.signal, - }) - } catch (error: unknown) { - recorder?.dispose() - throw error - } - recorder?.bind(run) + const run = ctx.workflows.start({ + script: args.script, + meta: args.meta, + ...args.args !== undefined ? { args: args.args } : {}, + parent, + signal: exec.signal, + }) + const recordsRun = exec.parent === undefined + if (recordsRun) recorder.start(parent.session, run) // Bridge the tool's abort signal to the run: if the parent step is aborted while the // script is in flight, cancel the whole run. The signal also enters the engine directly, but @@ -348,9 +318,9 @@ export function apply(ctx: Context, config: Config): void { // synthesize cancelled member endings while reaching quiescence. await run.dispose() /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ - if (result !== undefined) recorder?.finish(result.stopReason) + if (recordsRun && result !== undefined) recorder.finish(run.id, result.stopReason) } finally { - recorder?.dispose() + if (recordsRun) recorder.abandon(run.id) } } }, diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 5fb14908ca..127b6d8780 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -19,12 +19,9 @@ interface RunTrace { type WorkflowTrace = Map -/** Clone the independent fold before validating one candidate append. */ -function cloneTrace(source: WorkflowTrace): WorkflowTrace { - return new Map([...source].map(([runId, run]) => [runId, { - ended: run.ended, - members: new Map(run.members), - }])) +/** Whether this package owns the candidate Session event. */ +function isWorkflowRecordEvent(event: SessionEvent): boolean { + return event.type.startsWith('tool-workflow/') } /** Require a durable opaque identity to be a non-empty string. */ @@ -50,6 +47,23 @@ function recordOf(event: SessionEvent, fail: InvariantFailure): Record } +/** Copy only the run one candidate can mutate; other committed states stay shared. */ +function cloneTraceForEvent( + source: WorkflowTrace, + event: SessionEvent, + fail: InvariantFailure, +): WorkflowTrace { + const trace = new Map(source) + if (event.type === 'tool-workflow/run-start') return trace + const data = recordOf(event, fail) + const runId = stringId(data.runId, `${event.type} runId`, fail) + const run = source.get(runId) + if (run !== undefined) { + trace.set(runId, { ended: run.ended, members: new Map(run.members) }) + } + return trace +} + /** Require the named run to exist and remain open. */ function openRun(trace: WorkflowTrace, runId: string, eventType: string, fail: InvariantFailure): RunTrace { const run = trace.get(runId) @@ -60,7 +74,6 @@ function openRun(trace: WorkflowTrace, runId: string, eventType: string, fail: I /** Advance the workflow-record fold with one relevant Session event. */ function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void { - if (!event.type.startsWith('tool-workflow/')) return const data = recordOf(event, fail) const runId = stringId(data.runId, `${event.type} runId`, fail) @@ -107,6 +120,7 @@ function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFa fail(`tool-workflow/run-end leaves member seq ${openMembers.join(', ')} open in run ${runId}`) } run.ended = true + run.members.clear() return } default: @@ -126,23 +140,23 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): WorkflowTrace => { const trace: WorkflowTrace = new Map() - for (const event of session.events) applyChecked(trace, event, fail) + for (const event of session.events.filter(isWorkflowRecordEvent)) applyChecked(trace, event, fail) traces.set(session, trace) return trace } - /* v8 ignore next -- session/event always follows list() or session/created seeding. */ - const traceFor = (session: Session): WorkflowTrace => traces.get(session) ?? seed(session) - - for (const session of ctx.sessions.list()) seed(session) + ctx.sessions.list().forEach(seed) ctx.on('session/created', (session) => { seed(session) }, { global: true }) ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] - const trace = cloneTrace(traceFor(session)) + if (!isWorkflowRecordEvent(event)) return + // session/event dispatch follows list() or session/created seeding. + const trace = cloneTraceForEvent(traces.get(session) as WorkflowTrace, event, fail) applyChecked(trace, event, fail) staged.set(event, { session, trace }) }, { global: true }) ctx.on('session/event', (session, event) => { + if (!isWorkflowRecordEvent(event)) return const candidate = staged.get(event) /* v8 ignore next 2 -- internal/dispatch stages the exact session/event callback arguments. */ if (candidate === undefined || candidate.session !== session) { diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index ab1fd05a8d..142ca31fba 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -27,7 +27,6 @@ class StubEngine extends WorkflowService { settle!: (result: WorkflowResult) => void readonly settlements = new Map void>() startError: Error | undefined - emitMemberDuringStart = false start(request: WorkflowStartRequest): WorkflowRun { if (this.startError) throw this.startError @@ -35,12 +34,6 @@ class StubEngine extends WorkflowService { const id = WorkflowRunId(`run-${this.requests.length}`) const result = new Promise((resolve) => { this.settle = resolve }) this.settlements.set(id, this.settle) - if (this.emitMemberDuringStart) { - const info = { id, meta: request.meta } - const member = { seq: 1, label: 'synchronous', childId: SessionId('sync-child') } - this.emitWorkflowEvent('workflow/agent-start', info, member) - this.emitWorkflowEvent('workflow/agent-end', info, { ...member, outcome: 'completed' }) - } request.signal?.addEventListener('abort', () => { this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 }) }, { once: true }) @@ -205,23 +198,6 @@ describe('dsh-tool-workflow', () => { ]) }) - it('buffers synchronous member events until start returns the run identity', async () => { - const { ctx, engine, parent, session } = await setup() - engine.emitMemberDuringStart = true - const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent }) - await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) }) - engine.settleRun(WorkflowRunId('run-1'), { - value: null, stopReason: 'completed', agentsStarted: 1, - }) - expect((await pending).isError).toBe(false) - expect(session.events.map(event => event.type)).toEqual([ - 'tool-workflow/run-start', - 'tool-workflow/agent-start', - 'tool-workflow/agent-end', - 'tool-workflow/run-end', - ]) - }) - it('does not record nested transport executions', async () => { const { ctx, engine, parent, session } = await setup() const pending = execute(ctx, { script: SCRIPT, meta: META }, { From fff7dfac8eacc858fd72f6b41becf40bc726216f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 19:33:24 +0800 Subject: [PATCH 03/25] test(workflow): follow locale settings prerequisites --- packages/client/ui-workflow-run/tests/workflow-run.spec.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 8e3019df19..71ca4109a7 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -478,6 +478,7 @@ describe('plugin lifecycle', () => { it('registers and removes the Definition and keyed renderer with its fiber', async () => { const ctx = new Context() await ctx.plugin(SlotsService).await() + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) await ctx.plugin(ConversationEventRegistry).await() await ctx.plugin(TestSessions).await() ctx.slots.register({ From a7ddded2ef44bc806a96f9ee00806109ee62b130 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 20:56:31 +0800 Subject: [PATCH 04/25] fix(workflow): address ready review findings --- ...apse-workflow-to-foreground-core.i18n.yaml | 4 +- ...12-collapse-workflow-to-foreground-core.md | 12 ++-- ...collapse-workflow-to-foreground-core.zh.md | 12 ++-- .../snapshots/workflow-run/ui.expected.md | 8 +-- .../snapshots/workflow-run/session.jsonl | 2 +- .../src/client/WorkflowRunPanel.tsx | 60 +++++++++++-------- .../ui-workflow-run/src/client/locales.ts | 6 +- .../src/client/workflow-definition.ts | 16 +++-- .../tests/workflow-run.spec.tsx | 4 +- packages/workflow/tool-workflow/src/index.ts | 9 ++- .../workflow/tool-workflow/src/invariant.ts | 9 +-- 11 files changed, 80 insertions(+), 62 deletions(-) diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml index cc9f18fbef..9ade4e5770 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.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 .agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md -2026-07-12-collapse-workflow-to-foreground-core.md: 9151d9fb72a97aadf040fbdc13b5e0a4943f2f30 -2026-07-12-collapse-workflow-to-foreground-core.zh.md: c9eafe83e931de7aec4ec39e2471f0669c73609d +2026-07-12-collapse-workflow-to-foreground-core.md: 5fc46584f83eb5307ff16f3353b56951b928aef3 +2026-07-12-collapse-workflow-to-foreground-core.zh.md: 0b4c73e5df973215b10166f3dc2bbd525cc8231b diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md index 9151d9fb72..5fc46584f8 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md @@ -6,11 +6,15 @@ English | [中文](2026-07-12-collapse-workflow-to-foreground-core.zh.md) ## Problem -The workflow capability carries an observe-only lifecycle beside its execution handle. That surface can look removable because the script still completes without a UI listener, but it is the only provider-neutral source of the actual members that started, their exact labels and phases, and their paired outcomes. +The workflow capability executes foreground JavaScript that composes subagents, but it also carries an unconsumed progress-observation system. No production listener subscribes to any of the six `workflow/*` events; listeners exist only in workflow tests. Nevertheless the seam defines run/phase/agent outcome payloads, the worker sends phase/log/agent lifecycle protocol messages, the host forwards them through a `liveAgents` pairing ledger, and the engine maintains run ids solely to correlate those notifications. -The top-level `dsh-tool-workflow` consumer now uses those events to write four minimal `tool-workflow/*` facts into the calling parent Session, and `ui-workflow-run` rebuilds them into a durable Chat node. The consumer deliberately owns the projection because it alone holds the calling Agent, knows whether the tool execution is top-level, and can keep recording failure separate from workflow execution. `WorkflowRun.id` and `meta` therefore correlate live engine events with that exact durable record rather than duplicating presentation state. +The progress vocabulary is not merely unused; it cannot serve its only named future owner without redesign. `WorkflowRunInfo` contains `{id, meta}` but no parent agent, session, or tool-call identity, while the model-facing tool never exposes the run id. A global ACP listener could not route an event to the correct client session. `meta.phases` is never consulted, `phase(title)` does not validate against it, phase `detail`/`model` and agent `label`/`phase` feed only events, and `whenToUse` is validated and copied but never rendered or selected. `phase()` and `log()` still cross the worker boundary despite having no receiver. -Deleting the event vocabulary, member labels or phases, or run identity would remove the current replay and navigation result rather than merely simplify unused scaffolding. The rejected proposal below remains useful as the contraction to avoid; [durable workflow runs in Chat](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md) owns the present consumer and boundaries. +The live handle repeats event-era data after those observers disappear. `WorkflowRun.id` has no non-event consumer, while the tool reads `run.meta.name` only to render a value it already owns as `args.meta.name`; neither belongs on the execution/cancellation handle. + +Cancellation also has two public channels for one synchronous start. `WorkflowStartRequest.signal` is passed to the worker host, while the sole production caller separately bridges the same signal to `WorkflowRun.cancel()`. Because `start()` returns the run before control can yield, there is no readiness window that requires request-time cancellation; the duplicate signal adds host listener/disarm state without closing a race. + +`WorkflowError.fatal` is the same speculative branch in miniature: every production construction is fatal, `fatal: false` exists only in tests, and combinators already distinguish workflow failures with `instanceof`. ## Proposal @@ -20,7 +24,7 @@ Amend the implemented dynamic-workflow Agent Note and update the seam/tool/worke ## Alternatives considered -**Move durable recording into the workflow engine.** The engine knows run and member lifecycle but does not own the calling parent Session or the top-level-versus-nested tool boundary. Giving it those facts would couple a provider seam to one consumer and make recording failure part of engine execution. The tool-owned projection adds the missing ownership without widening worker messages or the service contract. +**Keep the prebuilt observation vocabulary for a future UI.** The current shape resembles Claude Code dynamic-workflow metadata, and the host deliberately pairs each forwarded agent start with either the worker's end or a synthesized terminal end. Removing it gives up compatibility-by-shape and makes progress UI a new design task, but the existing payloads still lack routable ownership, so balanced lifecycles alone cannot make the named ACP owner viable without redesign. ## Acceptance criteria diff --git a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md index c9eafe83e9..0b4c73e5df 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.zh.md @@ -6,11 +6,15 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 问题 -工作流能力在执行句柄之外还携带一套只供观察的生命周期。脚本即使没有 UI 监听器也能完成,因此这套界面看似可删除;但它是唯一与提供方无关、能够报告真正开始过的成员、精确标签与阶段以及配对结果的事实来源。 +工作流能力在前台执行用于编排 subagent 的 JavaScript,但它同时携带了一套无人消费的进度观测系统。没有任何生产环境的监听器订阅六个 `workflow/*` 事件中的任何一个;监听器仅存在于工作流测试中。尽管如此,seam 定义了 run/phase/agent(智能体)outcome 载荷,worker 发送 phase/log/agent 生命周期协议消息,host 通过一个 `liveAgents` 配对账本转发它们,引擎维护 run id 仅仅是为了关联这些通知。 -顶层 `dsh-tool-workflow` 消费方现在利用这些事件,把四类最小 `tool-workflow/*` 事实写入调用方父 Session;`ui-workflow-run` 再把它们重建为持久 Chat 节点。投影由消费方拥有,因为只有它同时持有调用 Agent、知道工具执行是顶层还是嵌套,并能让记录故障与工作流执行隔离。`WorkflowRun.id` 与 `meta` 因此用于把实时引擎事件关联到该条精确持久记录,而不是复制展示状态。 +这套进度词汇不仅仅是未被使用;它在不经重新设计的情况下也无法服务于其唯一已命名的未来消费方。`WorkflowRunInfo` 包含 `{id, meta}` 但没有父 agent、会话或工具调用标识,而面向模型的工具也从不暴露 run id。一个全局 ACP(Agent Client Protocol)监听器无法将事件路由到正确的客户端会话。`meta.phases` 从未被查询,`phase(title)` 不对其做校验,phase 的 `detail`/`model` 和 agent 的 `label`/`phase` 仅供事件消费,`whenToUse` 被校验和复制但从未被渲染或用于选择。`phase()` 和 `log()` 仍然跨越 worker 边界,尽管没有接收方。 -删除事件词汇、成员标签或阶段、运行身份,会移除当前回放和导航结果,而不再只是清理未使用脚手架。下方提案继续记录应避免的收缩;[Chat 中的持久工作流运行](../../implemented/feature/2026-08-10-durable-workflow-runs-in-chat.md)拥有当前消费方与边界。 +这些观测者移除后,live handle 仍重复携带事件机制所需的数据。`WorkflowRun.id` 没有非事件消费方,而工具读取 `run.meta.name` 只是为了渲染一个它已经以 `args.meta.name` 形式持有的值;两者都不属于执行/取消 handle。 + +取消机制也为一个同步启动提供了两条公开通道。`WorkflowStartRequest.signal` 被传递给 worker host,而唯一的生产调用方另外将同一个 signal 桥接到 `WorkflowRun.cancel()`。因为 `start()` 在控制权让出之前就返回了 run,不存在需要请求时取消的就绪窗口;重复的 signal 增加了 host 的 listener/disarm 状态却没有封堵任何竞态。 + +`WorkflowError.fatal` 是同一种推测性分支的微缩版:所有生产环境的构造都是 fatal 的,`fatal: false` 仅存在于测试中,组合子已经通过 `instanceof` 区分工作流失败。 ## 提案 @@ -20,7 +24,7 @@ Status: rejected — 工作流进度是有意设计的观测接口面;应通 ## 曾考虑的替代方案 -**把持久记录移入工作流引擎。** 引擎知道运行与成员生命周期,却不拥有调用方父 Session,也不知道顶层与嵌套工具边界。把这些事实交给引擎会让提供方 seam 耦合到单一消费方,并使记录故障进入引擎执行域。由工具拥有的投影补齐了缺失所有权,同时不扩展 worker 消息或 service 合同。 +**为未来 UI 保留预建的观测词汇。** 当前形态类似 Claude Code 的动态工作流元数据,host 有意地将每个转发的 agent start 与 worker 的 end 或一个合成的终止 end 配对。移除它意味着放弃形态兼容性,使进度 UI 成为一项全新的设计任务;但现有载荷仍缺少可路由的归属信息,因此仅靠平衡的生命周期也无法在不重新设计的情况下让已命名的 ACP 消费方可行。 ## 验收标准 diff --git a/apps/web/tests/snapshots/workflow-run/ui.expected.md b/apps/web/tests/snapshots/workflow-run/ui.expected.md index 297aad1b70..be377da995 100644 --- a/apps/web/tests/snapshots/workflow-run/ui.expected.md +++ b/apps/web/tests/snapshots/workflow-run/ui.expected.md @@ -13,12 +13,12 @@ - img - img - text: Tool call workflow · -- button "snapshot-flow 1 members Completed" [expanded]: +- button "snapshot-flow 1 member Completed" [expanded]: - img - - text: snapshot-flow 1 members Completed -- button "Run 1 members Completed 1" [expanded]: + - text: snapshot-flow 1 member Completed +- button "Run 1 member Completed 1" [expanded]: - img - - text: Run 1 members Completed 1 + - text: Run 1 member Completed 1 - text: Reply with exactly the word WF_CHILD_OK and not… Completed - button "Think The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop.": - img diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 16d284eb09..75efc1a3e0 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -28,7 +28,7 @@ {"type":"assistant/chunk","seq":173,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":174,"time0":1783600640862,"data":{"turn":1,"step":2,"index":0,"dt":[0,0,0,0,2,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," workflow"," returned"," successfully"," with"," the"," reply"," \"","WF","_CH","ILD","_OK","\"."," Now"," I"," need"," to"," reply"," with"," exactly"," \"","WORK","FL","OW","_D","ONE","\""," and"," stop","."]}} {"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":205,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,1898159500,231656974],"texts":["WORK","FL","OW","_D","ONE"]}} +{"type":"text-chunks","seq0":205,"time0":1783600640865,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,0],"texts":["WORK","FL","OW","_D","ONE"]}} {"type":"assistant/chunk","seq":210,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} {"type":"assistant/chunk","seq":211,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} {"type":"assistant/chunk","seq":212,"time":1786359246756,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index 8e48ffb4be..fcb36da7a3 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -1,9 +1,9 @@ -import { useMemo, useState } from 'react' +import { useState } from 'react' import { DisclosureRow, IconChevronRightOutline14, StateDot, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { shallowEqual, type SessionId, type SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkflowRunKey } from './locales.ts' import type { WorkflowRunMemberData, WorkflowRunPhaseData, WorkflowRunStatus, @@ -58,6 +58,10 @@ function statusCount( return t(`statusCount.${status}`, { count }) } +function memberCount(count: number, t: WorkflowRunPanelProps['t']): string { + return t(count === 1 ? 'run.members.one' : 'run.members.other', { count }) +} + function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: WorkflowRunPanelProps['t']): string { const counts = new Map() for (const member of members) counts.set(member.status, (counts.get(member.status) ?? 0) + 1) @@ -71,6 +75,28 @@ function phaseStatusSummary(members: readonly WorkflowRunMemberData[], t: Workfl return visible.map(status => statusCount(status, count(status), t)).join(' · ') } +function navigableMembers( + sessions: SessionListState, + phases: readonly WorkflowRunPhaseData[], + parentId: SessionId, +): readonly SessionId[] { + const ordinary = new Set(sessions.ids) + const result: SessionId[] = [] + for (const phase of phases) { + for (const member of phase.members) { + const summary = sessions.byId[member.childId] + if (member.status === 'running' + && ordinary.has(member.childId) + && summary?.origin === 'subagent' + && summary.parentId === parentId + && summary.running) { + result.push(member.childId) + } + } + } + return result +} + function RunHeader({ count, name, onToggle, open, status, t }: { readonly count: number readonly name: string @@ -95,7 +121,7 @@ function RunHeader({ count, name, onToggle, open, status, t }: { collapsedContent={( <> - {t('run.members', { count })} + {memberCount(count, t)} {t(STATUS_KEYS[status])} @@ -138,7 +164,7 @@ function MemberRow({ member, navigable, openSession, t }: { function PhaseSection({ phase, navigable, openSession, t }: { readonly phase: WorkflowRunPhaseData - readonly navigable: ReadonlySet + readonly navigable: readonly SessionId[] readonly openSession: WorkflowRunInjected['openSession'] readonly t: WorkflowRunPanelProps['t'] }) { @@ -161,7 +187,7 @@ function PhaseSection({ phase, navigable, openSession, t }: { collapsedContent={( <> - {t('run.members', { count: phase.members.length })} + {memberCount(phase.members.length, t)} {phaseStatusSummary(phase.members, t)} )} @@ -171,7 +197,7 @@ function PhaseSection({ phase, navigable, openSession, t }: { @@ -184,25 +210,11 @@ function PhaseSection({ phase, navigable, openSession, t }: { /** Render one durable workflow run with independent run and phase disclosure. */ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t }: WorkflowRunPanelProps) { const [open, setOpen] = useState(() => node.data.status === 'running') - const sessions = useSessions(value => value) const memberCount = node.data.phases.reduce((count, phase) => count + phase.members.length, 0) - const navigable = useMemo(() => { - const ordinary = new Set(sessions.ids) - const result = new Set() - for (const phase of node.data.phases) { - for (const member of phase.members) { - const summary = sessions.byId[member.childId] - if (member.status === 'running' - && ordinary.has(member.childId) - && summary?.origin === 'subagent' - && summary.parentId === sessionId - && summary.running) { - result.add(member.childId) - } - } - } - return result - }, [node.data.phases, sessionId, sessions]) + const navigable = useSessions( + sessions => navigableMembers(sessions, node.data.phases, sessionId), + shallowEqual, + ) return (
= { 'run.title': '{name}', - 'run.members': '{count} members', + 'run.members.one': '{count} member', + 'run.members.other': '{count} members', 'run.empty': 'No members started', 'phase.unassigned': 'Unphased', 'phase.empty': 'Empty phase name', diff --git a/packages/client/ui-workflow-run/src/client/workflow-definition.ts b/packages/client/ui-workflow-run/src/client/workflow-definition.ts index 3a4672d30b..2716988941 100644 --- a/packages/client/ui-workflow-run/src/client/workflow-definition.ts +++ b/packages/client/ui-workflow-run/src/client/workflow-definition.ts @@ -80,8 +80,7 @@ function statusFromOutcome(outcome: WorkflowAgentOutcome): WorkflowRunStatus { } } -function locationClosed(location: ConversationLocation | undefined): boolean { - if (location === undefined) return false +function locationClosed(location: ConversationLocation): boolean { if (location.kind === 'step') { return location.step.status === 'closed' || location.turn.status === 'closed' } @@ -90,11 +89,11 @@ function locationClosed(location: ConversationLocation | undefined): boolean { function projectWorkflow( context: ConversationNodeContext, -): WorkflowRunChatData | undefined { - const state = context.state - if (state === undefined) return undefined + location: ConversationLocation, +): WorkflowRunChatData { + const state = context.state as WorkflowState const interrupted = state.stopReason === undefined - && locationClosed(context.start?.location ?? context.matches[0]?.location) + && locationClosed(location) const phases = new Map() for (const member of state.members) { const phase = member.phase === undefined ? null : member.phase @@ -177,9 +176,8 @@ export const workflowRunDefinition: ConversationNodeDefinition = return context.state }, buildViewNode: (context, target): ChatConversationViewNode | null => { - if (target !== 'chat') return null - const data = projectWorkflow(context) - if (data === undefined || context.start === undefined) return null + if (target !== 'chat' || context.start === undefined) return null + const data = projectWorkflow(context, context.start.location) return { key: context.key, kind: 'workflow-run', diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 71ca4109a7..38e779f961 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -7,7 +7,7 @@ import { } from '@deepseek-ai/dsh-client-runtime/client' import type { ChatConversationViewNode, ConversationEventInput, ConversationMatch, ConversationNodeDefinition, - ConversationViewDefinition, ConversationViewNode, SessionId, SessionListState, + ConversationViewDefinition, SessionId, SessionListState, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -517,5 +517,3 @@ describe('plugin lifecycle', () => { expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-workflow-run']) }) }) - -void ({} as ConversationViewNode) diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index b479e6c9fc..ad0ee0e51d 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -289,6 +289,8 @@ export function apply(ctx: Context, config: Config): void { signal: exec.signal, }) const recordsRun = exec.parent === undefined + // The shipped worker-thread engine publishes member events from later + // worker messages, after start() returns and this run record is active. if (recordsRun) recorder.start(parent.session, run) // Bridge the tool's abort signal to the run: if the parent step is aborted while the @@ -317,8 +319,11 @@ export function apply(ctx: Context, config: Config): void { // Keep member listeners alive through disposal: an engine may // synthesize cancelled member endings while reaching quiescence. await run.dispose() - /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ - if (recordsRun && result !== undefined) recorder.finish(run.id, result.stopReason) + if (recordsRun) { + /* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */ + if (result === undefined) throw new Error('workflow run settled without a result') + recorder.finish(run.id, result.stopReason) + } } finally { if (recordsRun) recorder.abandon(run.id) } diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 127b6d8780..549b317379 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -128,11 +128,6 @@ function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFa } } -/** Apply one cold-load or live-append candidate through the package reporter. */ -function applyChecked(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void { - applyEvent(trace, event, fail) -} - /** Install an independent incremental fold over every attached Session. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { const traces = new WeakMap() @@ -140,7 +135,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant const seed = (session: Session): WorkflowTrace => { const trace: WorkflowTrace = new Map() - for (const event of session.events.filter(isWorkflowRecordEvent)) applyChecked(trace, event, fail) + for (const event of session.events.filter(isWorkflowRecordEvent)) applyEvent(trace, event, fail) traces.set(session, trace) return trace } @@ -152,7 +147,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant if (!isWorkflowRecordEvent(event)) return // session/event dispatch follows list() or session/created seeding. const trace = cloneTraceForEvent(traces.get(session) as WorkflowTrace, event, fail) - applyChecked(trace, event, fail) + applyEvent(trace, event, fail) staged.set(event, { session, trace }) }, { global: true }) ctx.on('session/event', (session, event) => { From 7f14c7e1650df7ee52b3e1348cf67b87512f49ae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:57:38 +0800 Subject: [PATCH 05/25] refactor(web): hand session exports to browser downloads The export endpoint already streams a ZIP response, but the web client immediately converted that response into a Blob. That forced the complete archive through JavaScript memory before a download could start and coupled transport, buffering, object-URL lifetime, and filename handling to the trajectory view. Navigate a temporary download anchor directly to the export endpoint instead. The browser now owns streaming and HTTP failure presentation, while a standalone delivery module owns URL construction and filename sanitization. Focused tests cover the handoff, rejection behavior, and the assembled session view; the package README and feature note record the new ownership boundary. --- ...026-08-10-web-session-log-export.i18n.yaml | 4 +- .../2026-08-10-web-session-log-export.md | 6 +-- .../2026-08-10-web-session-log-export.zh.md | 6 +-- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../ui-trajectory/src/client/export-log.ts | 31 ++++++++------- .../client/ui-trajectory/src/client/index.ts | 20 +--------- .../ui-trajectory/tests/export-log.spec.ts | 39 ++++++++++++++++--- .../client/ui-trajectory/tests/views.spec.tsx | 31 +++++---------- 10 files changed, 73 insertions(+), 72 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index 0c8a3f2781..aa518cf4e3 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 427b6478ac44fb28030aa932630f276de7bb2edc -2026-08-10-web-session-log-export.zh.md: 63b9804a54cda7eea4ff793d78a925fe296d06cb +2026-08-10-web-session-log-export.md: 6e9372ebec89f5aacef4e806fae77982d265c97b +2026-08-10-web-session-log-export.zh.md: e2f640efd735acd9e4d5d72bbfefdb01e7559161 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 427b6478ac..6e9372ebec 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -12,8 +12,8 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw - **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root), and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. - **Error vocabulary is HTTP-native**: missing services → 500, missing root session → 404 (both decided before any byte streams), a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. -- **The UI just downloads**: the 导出 button fetches the endpoint and saves the response; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle no longer carries fflate (the earlier browser-entry-alias pitfall is moot). -- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button; a failure surfaces in a visible alert bar under the toolbar. +- **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. +- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser. ## Alternatives considered @@ -26,5 +26,5 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw - Export fidelity: every exported file is byte-identical to the backend's durable artifact as of the read moment (a live session may append after the read; the export reflects the durable state at read time). The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. - `readRaw` joins the persistence service as a concrete default (`undefined` for backends without a per-session artifact, e.g. SQLite) with a JSONL-backend override that owns the compression decode. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. -- Fixture mode (no host) answers 404 for the export, so the button's error bar explains the gap instead of hanging; the navigation-panes golden snapshot includes the 导出 button. +- Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button. - Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 63b9804a54..e2f640efd7 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -12,8 +12,8 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 - **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本),且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 - **错误词汇是 HTTP 原生的**:服务缺失 → 500,根会话缺失 → 404(两者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 -- **UI 只负责下载**:「导出」按钮 fetch 该端点并保存响应;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不再携带 fflate(早先的浏览器入口别名坑随之消失)。 -- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会禁用按钮;失败会在工具栏下方的可见警示条中显示。 +- **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 +- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告。 ## 考虑过的替代方案 @@ -26,5 +26,5 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 - 导出保真度:每个导出文件都与读取时刻的后端持久化工件逐字节一致(活跃会话可能在读取后继续追加;导出反映的是读取时的持久化状态)。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 - `readRaw` 以具体默认(无每会话工件的后端如 SQLite 返回 `undefined`)加入持久化服务,jsonl 后端覆写并自持压缩解码。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 -- fixture 模式(无宿主)对导出应答 404,按钮的错误条会解释这个缺口而非挂起;navigation-panes golden 快照包含「导出」按钮。 +- fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。 - 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index baba46ae81..cad6321870 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.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 packages/client/ui-trajectory/README.md -README.md: e82b2cc9d4a65c3095aeee7002fb6c43a43b695d -README.zh.md: a1ba62393c2aae3f6baa7c481dd80f04dbbb477d +README.md: f4b3bd223c2872f0341d49bdaa102440d73b4f29 +README.zh.md: 9bcb3b6ad98d672cc524c168f2024be9ba56b577 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index e82b2cc9d4..f4b3bd223c 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button downloads the session log — the root plus every subagent descendant — as a ZIP streamed by the host (`GET /api/session.export`): every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents//session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/.`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button hands the session log — the root plus every subagent descendant — directly to the browser download manager as a ZIP streamed by the host (`GET /api/session.export`), so JavaScript never buffers the response: every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents//session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/.`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index a1ba62393c..9bcb3b6ad9 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——下载为宿主流式返回的 ZIP(`GET /api/session.export`):每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/.` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——作为宿主流式返回的 ZIP(`GET /api/session.export`)直接交给浏览器下载管理器,因此 JavaScript 不会缓冲响应:每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/.` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/src/client/export-log.ts b/packages/client/ui-trajectory/src/client/export-log.ts index 3a25794d4d..32a0aec0f1 100644 --- a/packages/client/ui-trajectory/src/client/export-log.ts +++ b/packages/client/ui-trajectory/src/client/export-log.ts @@ -1,7 +1,8 @@ /** - * Session log export: browser download of the host-streamed ZIP. The archive - * itself is produced and streamed by the host (GET /api/session.export); this - * module only derives the download filename and triggers the browser save. + * Session log export delivery. The host streams the archive from + * `GET /api/session.export`; this module owns the browser-native download + * handoff so the browser can stream the response directly to its download + * manager instead of buffering the ZIP in JavaScript. * @module */ @@ -27,16 +28,18 @@ export function sessionLogZipFilename(sessionId: string): string { } /** - * Trigger a browser download of a blob response. - * @param blob - the response body to save (passed straight through, no copy). - * @param filename - the download filename. + * Hand one host-streamed session archive to the browser download manager. + * The operation resolves after dispatching the native download; HTTP delivery + * continues outside JavaScript and is reported by the browser itself. + * @param sessionId - the root session id to export with all descendants. + * @returns a promise that rejects if the browser handoff itself fails. */ -export function downloadBlob(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = filename - anchor.click() - // Revoke one tick later: some browsers read the blob URL after click(). - setTimeout(() => { URL.revokeObjectURL(url) }, 0) +export function downloadSessionLog(sessionId: string): Promise { + return Promise.resolve().then(() => { + const query = new URLSearchParams({ sessionId, includeDescendants: 'true' }) + const anchor = document.createElement('a') + anchor.href = `/api/session.export?${query.toString()}` + anchor.download = sessionLogZipFilename(sessionId) + anchor.click() + }) } diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 8337e060c9..c8325f6442 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -10,7 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' // owning package) must be in the program for the register calls to type. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' -import { downloadBlob, sessionLogZipFilename } from './export-log.ts' +import { downloadSessionLog } from './export-log.ts' import { en, NS, zh } from './locales.ts' import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts' import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts' @@ -60,23 +60,7 @@ export function apply(ctx: Context): void { return session.getSnapshot().views.get('trajectory') !== before }, setActualDuration: (value) => { duration.set(value) }, - exportLog: async () => { - // The host streams the ZIP (root + descendant artifacts verbatim) - // from GET /api/session.export; the browser downloads the response. - // A null origin (no-location Node contexts) falls back like the - // carrier's resolveBase so the URL stays valid. - const loc = (globalThis as { location?: { origin?: string } }).location - const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal' - const url = new URL('/api/session.export', origin) - url.searchParams.set('sessionId', sessionId) - url.searchParams.set('includeDescendants', 'true') - const response = await fetch(url) - if (!response.ok) { - const detail = await response.text().catch(() => '') - throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) - } - downloadBlob(await response.blob(), sessionLogZipFilename(sessionId)) - }, + exportLog: () => downloadSessionLog(sessionId), } }, }, TrajectoryView)) diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.spec.ts index ba7f739573..6ff7d1ddbf 100644 --- a/packages/client/ui-trajectory/tests/export-log.spec.ts +++ b/packages/client/ui-trajectory/tests/export-log.spec.ts @@ -1,12 +1,15 @@ -// @vitest-environment node +// @vitest-environment jsdom /** - * Session-log export filename derivation. The archive itself is produced and - * streamed by the host (GET /api/session.export); this package only derives - * the download filename and triggers the browser save. + * Session-log export browser delivery: safe filename derivation and a native + * download handoff that leaves the streamed response outside JavaScript. */ -import { describe, expect, it } from 'vitest' -import { sessionLogZipFilename } from '../src/client/export-log.ts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { downloadSessionLog, sessionLogZipFilename } from '../src/client/export-log.ts' + +afterEach(() => { + vi.restoreAllMocks() +}) describe('sessionLogZipFilename', () => { it('keeps safe session ids verbatim', () => { @@ -22,3 +25,27 @@ describe('sessionLogZipFilename', () => { expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip') }) }) + +describe('downloadSessionLog', () => { + it('hands the descendant-inclusive endpoint directly to the browser', async () => { + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + await downloadSessionLog('session/with spaces') + + expect(click).toHaveBeenCalledOnce() + const anchor = click.mock.contexts[0] as HTMLAnchorElement + const url = new URL(anchor.href) + expect(url.pathname).toBe('/api/session.export') + expect(url.searchParams.get('sessionId')).toBe('session/with spaces') + expect(url.searchParams.get('includeDescendants')).toBe('true') + expect(anchor.download).toBe('dsh-session-session_with_spaces.zip') + }) + + it('rejects when the browser download handoff fails', async () => { + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => { + throw new Error('download denied') + }) + + await expect(downloadSessionLog('session-root')).rejects.toThrow('download denied') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index d95d70168b..ceacfdaf09 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -1141,39 +1141,26 @@ describe('timeline projection', () => { describe('session log export', () => { afterEach(() => { vi.unstubAllGlobals() - Reflect.deleteProperty(URL, 'createObjectURL') Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click') }) it('downloads the host-streamed ZIP with descendants on click', async () => { - // exportLog always fetches a URL instance, so the mock's shape stays narrow. - const fetchMock = vi.fn(async (input: URL) => { - expect(input.pathname).toBe('/api/session.export') - expect(input.searchParams.get('sessionId')).toBe(SID) - expect(input.searchParams.get('includeDescendants')).toBe('true') - return new Response('zip-bytes') - }) - vi.stubGlobal('fetch', fetchMock) - const createObjectURL = vi.fn(() => 'blob:export') - URL.createObjectURL = createObjectURL const clickAnchor = vi.fn() HTMLAnchorElement.prototype.click = clickAnchor const b = await bench(historySnapshot(NODES)) mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) fireEvent.click(screen.getByRole('button', { name: 'Export session log' })) - await vi.waitFor(() => { - expect(fetchMock).toHaveBeenCalledOnce() - }) - // The blob download lands a few microtasks after the fetch settles. - await vi.waitFor(() => { - expect(createObjectURL).toHaveBeenCalled() - }) - expect(clickAnchor).toHaveBeenCalled() + await vi.waitFor(() => { expect(clickAnchor).toHaveBeenCalledOnce() }) + const anchor = clickAnchor.mock.contexts[0] as HTMLAnchorElement + const url = new URL(anchor.href) + expect(url.pathname).toBe('/api/session.export') + expect(url.searchParams.get('sessionId')).toBe(SID) + expect(url.searchParams.get('includeDescendants')).toBe('true') }) - it('surfaces the download failure in the visible alert bar', async () => { - vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 }))) + it('surfaces a browser handoff failure in the visible alert bar', async () => { + HTMLAnchorElement.prototype.click = vi.fn(() => { throw new Error('download denied') }) const b = await bench(historySnapshot(NODES)) mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) @@ -1181,7 +1168,7 @@ describe('session log export', () => { await vi.waitFor(() => { const alert = screen.queryByRole('alert') expect(alert).not.toBeNull() - expect(alert!.textContent).toContain('HTTP 404') + expect(alert!.textContent).toContain('download denied') }) }) }) From e58cc13de4834c181e2fe9dd9d9b28996a814ea3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:04:35 +0800 Subject: [PATCH 06/25] fix(session-export): distinguish unsupported raw artifacts SessionPersistence.readRaw previously used undefined for two unrelated states: a supported backend could not find the requested session, or the backend had no per-session artifact concept at all. The export endpoint consequently reported an existing SQLite-backed session as HTTP 404, which falsely diagnosed storage capability as session absence. Make raw-artifact support an explicit backend capability. Unsupported backends now fail their inherited readRaw path loudly and the host answers 501 before reading, while undefined retains the single meaning of an absent artifact on a supporting backend. First-party backends, test providers, generated API catalogs, bilingual persistence docs, and export error contracts now state that distinction; focused tests cover both the 501 and the inherited rejection. --- .../2026-08-10-web-session-log-export.i18n.yaml | 4 ++-- .../feature/2026-08-10-web-session-log-export.md | 4 ++-- .../feature/2026-08-10-web-session-log-export.zh.md | 4 ++-- docs/subsystems/persistence.i18n.yaml | 4 ++-- docs/subsystems/persistence.md | 10 ++++++---- docs/subsystems/persistence.zh.md | 10 ++++++---- packages/feedback/message-feedback/tests/helpers.ts | 2 ++ packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 6 ++++++ packages/host/apiproxy/tests/session-export.spec.ts | 12 +++++++++++- .../tool-cordis/src/api-catalog.ts | 2 +- .../session-query-sqlite/tests/sqlite.spec.ts | 2 ++ .../session-query/tests/session-query.spec.ts | 2 ++ .../session-query/tests/tracing.spec.ts | 2 ++ .../tests/session-checkpoint-policy.spec.ts | 2 ++ .../session/session-persistence-jsonl/src/index.ts | 2 ++ .../session/session-persistence-sqlite/src/index.ts | 2 ++ .../session/session-persistence/README.i18n.yaml | 4 ++-- packages/session/session-persistence/README.md | 2 ++ packages/session/session-persistence/README.zh.md | 2 ++ packages/session/session-persistence/src/index.ts | 13 +++++++++---- .../session-persistence/tests/persistence.spec.ts | 9 +++++++-- 24 files changed, 78 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index aa518cf4e3..35d7236dd2 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 6e9372ebec89f5aacef4e806fae77982d265c97b -2026-08-10-web-session-log-export.zh.md: e2f640efd735acd9e4d5d72bbfefdb01e7559161 +2026-08-10-web-session-log-export.md: 838fbc77c82e8472ccf419e2d55e0396212fd1ba +2026-08-10-web-session-log-export.zh.md: 5d9b168ea99f02211aafc794a36f8c7d2bb002a8 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 6e9372ebec..838fbc77c8 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -11,7 +11,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision - **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root), and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. -- **Error vocabulary is HTTP-native**: missing services → 500, missing root session → 404 (both decided before any byte streams), a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. +- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. - **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. - The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser. @@ -25,6 +25,6 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Consequences - Export fidelity: every exported file is byte-identical to the backend's durable artifact as of the read moment (a live session may append after the read; the export reflects the durable state at read time). The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. -- `readRaw` joins the persistence service as a concrete default (`undefined` for backends without a per-session artifact, e.g. SQLite) with a JSONL-backend override that owns the compression decode. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. +- `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. - Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button. - Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index e2f640efd7..5d9b168ea9 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -11,7 +11,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 - **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本),且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 -- **错误词汇是 HTTP 原生的**:服务缺失 → 500,根会话缺失 → 404(两者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 - **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 - 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告。 @@ -25,6 +25,6 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 后果 - 导出保真度:每个导出文件都与读取时刻的后端持久化工件逐字节一致(活跃会话可能在读取后继续追加;导出反映的是读取时的持久化状态)。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 -- `readRaw` 以具体默认(无每会话工件的后端如 SQLite 返回 `undefined`)加入持久化服务,jsonl 后端覆写并自持压缩解码。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 +- `supportsRawArtifacts` 明确区分后端能力与会话缺失:SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝;JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 - fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。 - 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 1c442fb1a2..a5b6416e90 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.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 docs/subsystems/persistence.md -persistence.md: fd694161ed8ae4c364de5c22d8eb06f1b0a91aec -persistence.zh.md: b616b282204e946e18e90271d1eaeb2d4ed70fc3 +persistence.md: d63fbaa22adead19fa53ae717e7d589f823f885a +persistence.zh.md: 31cf598ec4e6d6acca1111b5d576d585a49d362a diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index fd694161ed..d63fbaa22a 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -124,7 +124,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi ## `SessionRawArtifact` — verbatim stored artifact text -A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives; backends without a per-session artifact, such as SQLite, inherit the `undefined` default. +A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives. Consumers first test `supportsRawArtifacts`: `false` means the backend does not provide this capability (for example SQLite), while `readRaw(...) === undefined` means a supported backend has no materialized artifact for that session. ```ts type-equiv /** A backend's own raw artifact text for one session, verbatim. */ @@ -262,13 +262,15 @@ abstract locate(meta: SessionHeader): SessionLocation | undefined * bytes the backend wrote (decoded from its physical encoding, e.g. a * decompressed JSONL). The returned `content` is the raw text, not a * reconstruction from parsed events, so it preserves backend-specific - * serialization (chunk packing, key order, line breaks). Backends without a - * per-session artifact (SQLite) inherit the `undefined` default. + * serialization (chunk packing, key order, line breaks). Callers first test + * {@link supportsRawArtifacts}; `undefined` then means only that the requested + * session has no materialized artifact. * @param _id - the persisted session to read (unused by the default: no * per-session artifact). * @param signal - optional cancellation for backend read work. * @returns the raw artifact plus its parsed header, or `undefined` when the - * session is absent or the backend owns no per-session artifact. + * session is absent. + * @throws when this backend does not expose per-session raw artifacts. */ readRaw(_id: SessionId, signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index b616b28220..31cf598ec4 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -124,7 +124,7 @@ interface CreateSessionOptions { ## `SessionRawArtifact`——逐字存储工件文本 -后端为单个会话自持的工件文本,与其持久化写入的字节逐字一致(按物理编码解码)。`readRaw` 返回它而不从解析后事件重建,因此后端特定的序列化(chunk 打包、键序、换行)得以保留;没有每会话工件的后端(如 SQLite)继承 `undefined` 默认。 +后端为单个会话自持的工件文本,与其持久化写入的字节逐字一致(按物理编码解码)。`readRaw` 返回它而不从解析后事件重建,因此后端特定的序列化(chunk 打包、键序、换行)得以保留。Consumer 须先检查 `supportsRawArtifacts`:`false` 表示后端不提供此能力(如 SQLite),而 `readRaw(...) === undefined` 表示受支持的后端没有该会话的已实体化工件。 ```ts type-equiv /** A backend's own raw artifact text for one session, verbatim. */ @@ -262,13 +262,15 @@ abstract locate(meta: SessionHeader): SessionLocation | undefined * bytes the backend wrote (decoded from its physical encoding, e.g. a * decompressed JSONL). The returned `content` is the raw text, not a * reconstruction from parsed events, so it preserves backend-specific - * serialization (chunk packing, key order, line breaks). Backends without a - * per-session artifact (SQLite) inherit the `undefined` default. + * serialization (chunk packing, key order, line breaks). Callers first test + * {@link supportsRawArtifacts}; `undefined` then means only that the requested + * session has no materialized artifact. * @param _id - the persisted session to read (unused by the default: no * per-session artifact). * @param signal - optional cancellation for backend read work. * @returns the raw artifact plus its parsed header, or `undefined` when the - * session is absent or the backend owns no per-session artifact. + * session is absent. + * @throws when this backend does not expose per-session raw artifacts. */ readRaw(_id: SessionId, signal?: AbortSignal): Promise diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index 1dfaa24396..352145d21f 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -109,6 +109,8 @@ export function messageFixture( /** Minimal controllable persistence provider for service-level tests. */ class TestPersistence extends SessionPersistence { + override readonly supportsRawArtifacts = false + static inject = ['sessions'] readonly durable = new Map() diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 72568f241d..ae68b4de07 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: 5fe19af8069766c56f8926ccef88dc1d9fb3c950 -README.zh.md: bdb26a63832c1461b4e56798e64c1253a916118d +README.md: a597f344e528e8521a7e79672e980f6dd855d5b6 +README.zh.md: 13657e1e502f33423c7c2cda481d5decdfb505ec diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 5fe19af806..a597f344e5 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. -Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a missing root session 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index bdb26a6383..13657e1e50 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 -会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c4e0a16756..32a9398a82 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -3489,6 +3489,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro { status: 500 }, ) } + if (!deps.sessionPersistence.supportsRawArtifacts) { + return new Response( + 'session log export is unavailable: the persistence backend does not expose per-session raw artifacts', + { status: 501 }, + ) + } const ready: SessionLogExportReady = { sessionQuery: deps.sessionQuery, sessionPersistence: deps.sessionPersistence, diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 923e70b380..54f99acde2 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -59,7 +59,7 @@ async function buildApi( descendants: SessionLineageNode[] = [], services: { query?: boolean - persistence?: boolean | 'throw' + persistence?: boolean | 'throw' | 'unsupported' attachments?: boolean | ((ref: ImageAttachmentRef) => Promise>) } = {}, ) { @@ -80,6 +80,7 @@ async function buildApi( } if (persistence) { ctx.provide('sessionPersistence', { + supportsRawArtifacts: persistence !== 'unsupported', readRaw: async (id: SessionId) => { if (persistence === 'throw') throw new Error('/host/private/session.jsonl') return artifacts[id] @@ -151,6 +152,15 @@ describe('session.export download endpoint', () => { expect(response.status).toBe(404) }) + it('answers 501 when the persistence backend has no per-session raw artifacts', async () => { + const api = await buildApi({}, [], { persistence: 'unsupported' }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(501) + expect(await response.text()).toContain('does not expose per-session raw artifacts') + }) + it('answers 400 when the sessionId query parameter is absent', async () => { const api = await buildApi({ 'session-root': artifact('session-root') }) const response = await toFetchHandler(api).fetch( diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 1c310ab8e0..0a17057509 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -720,7 +720,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'readRaw(_id: SessionId, signal?: AbortSignal): Promise', - jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Backends without a\n * per-session artifact (SQLite) inherit the `undefined` default.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent or the backend owns no per-session artifact.\n */', + jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Callers first test\n * {@link supportsRawArtifacts}; `undefined` then means only that the requested\n * session has no materialized artifact.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent.\n * @throws when this backend does not expose per-session raw artifacts.\n */', }, { signature: 'abstract create(meta: SessionHeader): Promise', diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index bbba91453d..4b27f056af 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -67,6 +67,8 @@ function replaceCursorOffset( } class TestPersistence extends SessionPersistence { + override readonly supportsRawArtifacts = false + static entries = new Map() static revisions = new Map() static nextRevision = 0 diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 5a4228329b..a61be6a7a0 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -29,6 +29,8 @@ function eventLog(text = 'hello'): SessionEvent[] { } class TestPersistence extends SessionPersistence { + override readonly supportsRawArtifacts = false + static entries = new Map() static listFailure: unknown static listOverride: ((signal?: AbortSignal) => Promise) | undefined diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 8c9588be26..c9e9d2ad50 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -32,6 +32,8 @@ function appendEvent(seq: number, sources?: number[]): SessionEvent { } class TracePersistence extends SessionPersistence { + override readonly supportsRawArtifacts = false + static entries = new Map() static listCalls = 0 static inspectCalls = 0 diff --git a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 6501941c77..2ed880e355 100644 --- a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -13,6 +13,8 @@ import * as checkpointPolicy from '../src/index.ts' const contexts: Context[] = [] class TestPersistence extends SessionPersistence { + override readonly supportsRawArtifacts = false + locate(_meta: SessionHeader): undefined { return undefined } create(_meta: SessionHeader): Promise { return Promise.resolve() } append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 42a3c431ce..57a20ebb07 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -119,6 +119,8 @@ function isENOENT(error: unknown): boolean { * recovered from an incomplete final Zstandard frame. */ export class SessionPersistenceJsonl extends SessionPersistence implements PersistenceBackend { + override readonly supportsRawArtifacts = true + static inject = ['sessions'] static Config: z = z.object({ diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index 15cf869b69..c9cf6ea95b 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -97,6 +97,8 @@ export interface Config { * listeners. Its torn-tail marker is the seq to delete from. */ export class SessionPersistenceSqlite extends SessionPersistence implements PersistenceBackend { + override readonly supportsRawArtifacts = false + static inject = ['sessions'] static Config: z = z.object({ diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index eed71ad212..bd33846baa 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.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 packages/session/session-persistence/README.md -README.md: 324c00b3202bd136566137e1bd398b29d2ea4b82 -README.zh.md: 2ef5e9a90f0323f8edf8fdc4f936c41ca7e08c70 +README.md: 09aa7ad8263454d6c5edbb2358033370504c0cb9 +README.zh.md: 901c41b6894d86bdc4ffb345314a3dd506e4a770 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 324c00b320..09aa7ad826 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -11,6 +11,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | Method | Contract | |---|---| | `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. | +| `supportsRawArtifacts: boolean` | State explicitly whether this backend exposes one verbatim artifact per session. Consumers check this capability before calling `readRaw`; `false` is not session absence. | +| `readRaw(id, signal?): Promise` | Read a supported backend's own artifact text verbatim, decoded from its physical encoding but never reconstructed from events. `undefined` means only that the requested artifact is absent; an unsupported backend rejects. | | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `prepare(id, signal?): Promise` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 2ef5e9a90f..901c41b689 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -11,6 +11,8 @@ | 方法 | 约定 | |---|---| | `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 | +| `supportsRawArtifacts: boolean` | 明确说明该后端是否为每个会话暴露一份逐字工件。Consumer 在调用 `readRaw` 前检查此能力;`false` 并不表示会话缺失。 | +| `readRaw(id, signal?): Promise` | 读取受支持后端自身的逐字工件文本;只解码物理编码,绝不从事件重建。`undefined` 仅表示所请求工件缺失;不支持的后端会拒绝。 | | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index aa01f68f7a..07a8819ca1 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -95,24 +95,29 @@ export abstract class SessionPersistence extends Service { */ abstract locate(meta: SessionHeader): SessionLocation | undefined + /** Whether this backend exposes one verbatim raw artifact per session. */ + abstract readonly supportsRawArtifacts: boolean + /** * Read a session's backend-owned artifact text verbatim — the exact durable * bytes the backend wrote (decoded from its physical encoding, e.g. a * decompressed JSONL). The returned `content` is the raw text, not a * reconstruction from parsed events, so it preserves backend-specific - * serialization (chunk packing, key order, line breaks). Backends without a - * per-session artifact (SQLite) inherit the `undefined` default. + * serialization (chunk packing, key order, line breaks). Callers first test + * {@link supportsRawArtifacts}; `undefined` then means only that the requested + * session has no materialized artifact. * @param _id - the persisted session to read (unused by the default: no * per-session artifact). * @param signal - optional cancellation for backend read work. * @returns the raw artifact plus its parsed header, or `undefined` when the - * session is absent or the backend owns no per-session artifact. + * session is absent. + * @throws when this backend does not expose per-session raw artifacts. */ readRaw(_id: SessionId, signal?: AbortSignal): Promise { if (signal?.aborted === true) { return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('aborted')) } - return Promise.resolve(undefined) + return Promise.reject(new Error('this session persistence backend does not expose raw artifacts')) } /** diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index a09516df29..d37a14f63b 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -68,6 +68,8 @@ interface CoordinatorInternals { * durable behavior is covered by the JSONL and SQLite backends. */ class MemoryPersistence extends SessionPersistence implements PersistenceBackend { + override readonly supportsRawArtifacts = false + static inject = ['sessions'] override readonly name = 'session-persistence-memory' @@ -247,11 +249,14 @@ runPersistenceContract('memory', async () => { }) describe('the inherited readRaw default', () => { - it('answers undefined and honors an aborted signal', async () => { + it('rejects unsupported reads distinctly from absence and honors an aborted signal', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(MemoryPersistence) - expect(await ctx.sessionPersistence.readRaw(SessionId('any-session'))).toBeUndefined() + expect(ctx.sessionPersistence.supportsRawArtifacts).toBe(false) + await expect( + ctx.sessionPersistence.readRaw(SessionId('any-session')), + ).rejects.toThrow('does not expose raw artifacts') await expect( ctx.sessionPersistence.readRaw(SessionId('any-session'), AbortSignal.abort()), ).rejects.toThrow() From 904c3f2c358578e15cefe3f2894e425357b14505 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:05:54 +0800 Subject: [PATCH 07/25] fix(session-persistence-jsonl): reject empty zstd artifacts A present zero-byte .jsonl.zstd file was treated as though no artifact existed because readRaw returned undefined when frame scanning found nothing. That contradicted both the plaintext path and the logical zstd reader, and it made the export endpoint answer 404 for on-disk corruption. Treat a present artifact without a complete header frame as corruption and reuse the zstd reader's existing diagnostic. The regression test now distinguishes an existing empty file from an absent path, and the bilingual JSONL storage contract records that zero-frame artifacts reject alongside other header and frame failures. --- .../session/session-persistence-jsonl/README.i18n.yaml | 4 ++-- packages/session/session-persistence-jsonl/README.md | 2 +- packages/session/session-persistence-jsonl/README.zh.md | 2 +- packages/session/session-persistence-jsonl/src/index.ts | 2 +- .../session/session-persistence-jsonl/tests/zstd.spec.ts | 8 ++++---- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index a1fcc59e7f..37905ff25b 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.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 packages/session/session-persistence-jsonl/README.md -README.md: 628833513a8092280970230c8657a50d00db4527 -README.zh.md: 4eb2d4f2bebf9ed17190ef3cb21a2bc3c8d9123b +README.md: 540ddb6db67adb1a36c8feea946b6843e614e035 +README.zh.md: 7e3ba5be4f2707ff6408d296ece1f43550d76286 diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 628833513a..540ddb6db6 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -42,7 +42,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the - **Bound storage identity.** Lookup requires one matching session directory across the readable project directories, then verifies that the header id equals the requested id and that the header's id/cwd derive the selected transcript path. Listing applies the same path check and rejects duplicate ids. Identity failures occur before repair or append. - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length. -- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects. +- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. An existing compressed artifact with no complete header frame, a checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end` is corruption and rejects. - **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without truncating an incomplete tail or changing the lightweight revision. - **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. - **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. A full-prefix read requires the same identity before and after reading the bytes, and `readStoredRevision()` uses that identity to validate retained preparations without loading the log. Snapshot listing forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 4eb2d4f2be..7e3ba5be4f 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -42,7 +42,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d - **绑定存储身份。** 查找要求可读项目目录中只有一个匹配会话目录,然后验证 header id 等于请求 id,且 header id/cwd 派生所选 transcript 路径。列表应用同一路径检查,并拒绝重复 id。身份失败发生在修复或 append 前。 - **延迟实体化。**`create(meta)` 不写入;第一次 `append` 将编码 header 和第一批写入临时文件并执行 `fsync`。POSIX 通过硬链接无覆盖发布,并对父目录 `fsync`。Windows 通过 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 无覆盖发布,并通过同一 write-through pattern 创建缺失目录。已创建但从未 append 的会话不留下磁盘内容,不在 `list` 中。 - **仅追加。** 已 flush 事件绝不重写。后续原始批次 append 行;压缩批次 append 一个 frame。两条路径都执行 `fsync`,并在捕获到写入或同步失败时回滚到之前字节长度。 -- **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷属于损坏,会被拒绝。 +- **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。已经存在却没有完整 header frame 的压缩工件、完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷都属于损坏,会被拒绝。 - **非变更检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会截断不完整尾部或更改轻量修订。 - **连续 seq。**`append` 拒绝第一个 `seq` 不继续已存储日志的批次,并拒绝非 JSON 可序列化 `event.data`,同时命名违规事件类型。 - **轻量修订。**`listSnapshots(signal?)` 使用 device、inode、size 和纳秒时间戳标识日志,避免解析完整日志;该标识会在 append、修复、替换或存储变更后改变。完整前缀读取要求读取字节前后的身份一致,`readStoredRevision()` 使用同一身份校验保留的 preparation,而不加载日志。快照列表通过产物发现转发精确信号,并在每个 `stat` 前后检查取消;由于文件系统 `stat` 不可中断,取消会等待活动调用完成,然后在不启动另一次调用的情况下拒绝。 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 57a20ebb07..95b9a4f74c 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -259,7 +259,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi let content: string if (this.compression === 'zstd') { const { frames } = scanZstdFrames(buffer) - if (frames.length === 0) return undefined + if (frames.length === 0) throw new Error('empty or header-less Zstandard session log') const decoder = createZstdFrameDecoder() const plaintexts: Buffer[] = [] // The decoder yields views into a reused buffer; copy each frame's diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index 49d1182b44..0b7bb5d93d 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -377,16 +377,16 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) }) - it('readRaw is undefined for a zstd artifact that carries no frame', async () => { + it('readRaw rejects a present zstd artifact that carries no frame', async () => { const root = await freshRoot() const ctx = await mount(root) const header = meta('raw-zero-frame', '/work') await ctx.sessionPersistence.create(header) await ctx.sessionPersistence.append(header.id, oneTurnLog()) - // Overwrite the physical artifact with a short buffer: frame scanning - // answers zero frames before any magic check, so readRaw reports no artifact. + // The path still exists, so zero frames is corruption rather than absence. await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) - expect(await ctx.sessionPersistence.readRaw(header.id)).toBeUndefined() + await expect(ctx.sessionPersistence.readRaw(header.id)) + .rejects.toThrow('empty or header-less Zstandard session log') }) it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { From 52f0b09e765f3b03ad4a5827f47647a57683cbbb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:09:13 +0800 Subject: [PATCH 08/25] fix(session-export): flush live logs before raw reads The exporter read persistence artifacts directly even when the requested root or a descendant was still live. Buffered session events could therefore be omitted from a successful download, so the advertised verbatim-artifact guarantee described storage accurately but captured an arbitrarily stale durability boundary. Resolve each id against SessionStore and cross its authoritative flush barrier immediately before readRaw. Cold sessions remain a no-op, while live roots and descendants are made durable independently; this intentionally yields a per-session read-boundary snapshot rather than claiming an atomic lineage snapshot. A host-path regression test proves both artifacts change from stale to durable only through flush, and the bilingual host contract and Agent Note document the boundary. --- ...026-08-10-web-session-log-export.i18n.yaml | 4 +- .../2026-08-10-web-session-log-export.md | 2 +- .../2026-08-10-web-session-log-export.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 3 ++ packages/host/apiproxy/src/session-export.ts | 40 +++++++++++--- .../apiproxy/tests/session-export.spec.ts | 54 +++++++++++++++++++ 9 files changed, 99 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index 35d7236dd2..b781cad5a1 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 838fbc77c82e8472ccf419e2d55e0396212fd1ba -2026-08-10-web-session-log-export.zh.md: 5d9b168ea99f02211aafc794a36f8c7d2bb002a8 +2026-08-10-web-session-log-export.md: a290eb7043833b66a217476a86c985ec8c9f33de +2026-08-10-web-session-log-export.zh.md: b02fff598048250fe747f124ad86cd98352aa28f diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 838fbc77c8..a290eb7043 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -24,7 +24,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Consequences -- Export fidelity: every exported file is byte-identical to the backend's durable artifact as of the read moment (a live session may append after the read; the export reflects the durable state at read time). The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. +- Export fidelity: immediately before reading each live root or descendant, the exporter crosses the authoritative `SessionStore.flush` durability barrier; every exported file is byte-identical to that resulting durable artifact. A live session may append again after its read, so the archive is a per-session read-boundary snapshot rather than one atomic tree snapshot. The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. - `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. - Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button. - Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 5d9b168ea9..b02fff5980 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -24,7 +24,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 后果 -- 导出保真度:每个导出文件都与读取时刻的后端持久化工件逐字节一致(活跃会话可能在读取后继续追加;导出反映的是读取时的持久化状态)。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 +- 导出保真度:读取每个实时根会话或后代前,导出器会通过权威的 `SessionStore.flush` 持久性屏障;每个导出文件都与由此得到的持久化工件逐字节一致。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 - `supportsRawArtifacts` 明确区分后端能力与会话缺失:SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝;JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 - fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。 - 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index ae68b4de07..16ffe6245c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: a597f344e528e8521a7e79672e980f6dd855d5b6 -README.zh.md: 13657e1e502f33423c7c2cda481d5decdfb505ec +README.md: 1d9790beba0b72bec15c3e6f3b35a4d1f0f67d61 +README.zh.md: 478088f1167edd4e3c2b55c33e914e85349d2bba diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index a597f344e5..1d9790beba 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. -Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 13657e1e50..478088f116 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 -会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 32a9398a82..86a85c18ae 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -43,6 +43,7 @@ import type { WorkspaceId, WorkspaceView, } from './api/index.ts' import { + flushLiveSessionLog, sessionLogExportDeps, sessionLogZipFilename, streamSessionLogZip, @@ -3499,9 +3500,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro sessionQuery: deps.sessionQuery, sessionPersistence: deps.sessionPersistence, attachments: deps.attachments, + sessions: deps.sessions, } let root: SessionRawArtifact | undefined try { + await flushLiveSessionLog(deps, request.sessionId, signal) root = await deps.sessionPersistence.readRaw(request.sessionId, signal) } catch { // Backend read failure: answer 500 without echoing the error, which diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts index 73026be20a..992bfc0ea7 100644 --- a/packages/host/apiproxy/src/session-export.ts +++ b/packages/host/apiproxy/src/session-export.ts @@ -6,7 +6,9 @@ * by any included log under `media/.` (content-addressed, * so one archive never duplicates a shared image). No manifest is written — * every file is byte-identical to the backend's durable artifact or attachment - * store and self-describing through its own header line or media type. + * store and self-describing through its own header line or media type. Before + * each live session's artifact read, the SessionStore flush barrier makes the + * current in-memory log durable; cold sessions need no barrier. * Compression runs on the host with fflate's streaming Zip API, so the archive * bytes are produced incrementally and the host never holds the whole archive * in one buffer; production yields to the consumer whenever the response queue @@ -20,14 +22,15 @@ import { Zip, ZipDeflate } from 'fflate' import type { Context } from '@deepseek-ai/cordis' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session' import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' -/** The services a session-log export needs (absent → the export is unavailable). */ +/** The services a session-log export needs (the live-session store is optional). */ export interface SessionLogExportDeps { readonly sessionQuery: SessionQueryService | undefined readonly sessionPersistence: SessionPersistence | undefined readonly attachments: AttachmentStore | undefined + readonly sessions: SessionStore | undefined } /** The export services narrowed to the mounted ones streaming actually reads. */ @@ -35,6 +38,7 @@ export interface SessionLogExportReady { readonly sessionQuery: SessionQueryService readonly sessionPersistence: SessionPersistence readonly attachments: AttachmentStore + readonly sessions: SessionStore | undefined } /** @@ -47,9 +51,32 @@ export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps { sessionQuery: ctx.get('sessionQuery'), sessionPersistence: ctx.get('sessionPersistence'), attachments: ctx.get('attachments'), + sessions: ctx.get('sessions'), } } +/** + * Flush one currently live session through the store's authoritative durability + * barrier immediately before its raw artifact is read. A cold or absent id has + * no in-memory work to flush. + * @param deps - export services, including the optional live-session store. + * @param id - the session whose artifact is about to be read. + * @param signal - optional cancellation observed around the flush barrier. + */ +export async function flushLiveSessionLog( + deps: Pick, + id: SessionId, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted() + const sessions = deps.sessions + if (sessions === undefined) return + const session = sessions.get(id) + if (session === undefined) return + await sessions.flush(session) + signal?.throwIfAborted() +} + /** One exported file: a stored artifact text or one referenced media object. */ export type SessionLogZipEntry = | { readonly path: string; readonly content: string } @@ -168,9 +195,9 @@ export function sessionLogZipFilename(sessionId: string): string { /** * Yield the export entries in zip order: the preloaded root artifact first, - * then every subagent descendant in lineage order (each read from the - * persistence backend right before it is yielded and dropped after the - * consumer moves on), then every distinct media object referenced by any of + * then every subagent descendant in lineage order (each flushed when live, + * read from the persistence backend right before it is yielded, and dropped + * after the consumer moves on), then every distinct media object referenced by any of * the included logs (read and verified from the attachment store, one archive * entry per attachment id). The host holds at most one descendant's artifact * text and one media object at a time beyond the root. @@ -205,6 +232,7 @@ export async function* sessionLogZipEntries( const id = node.session.header.id if (seen.has(id)) continue seen.add(id) + await flushLiveSessionLog(deps, id, signal) const raw = await deps.sessionPersistence.readRaw(id) if (raw === undefined) { throw new Error(`subagent "${id}" has no stored log artifact`) diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 54f99acde2..968a7529c5 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -61,6 +61,10 @@ async function buildApi( query?: boolean persistence?: boolean | 'throw' | 'unsupported' attachments?: boolean | ((ref: ImageAttachmentRef) => Promise>) + sessions?: { + get(id: SessionId): { readonly id: SessionId } | undefined + flush(session: { readonly id: SessionId }): Promise + } } = {}, ) { const ctx = new Context() @@ -98,6 +102,7 @@ async function buildApi( readImage, } as never) } + if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never) return createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', @@ -144,6 +149,55 @@ describe('session.export download endpoint', () => { .toBe(artifact('child-a').content) }) + it('flushes each live root and descendant immediately before reading its artifact', async () => { + const stored: Record = { + 'session-root': artifact('session-root', undefined, 'stale root'), + 'child-a': artifact('child-a', sid('session-root'), 'stale child'), + } + const durable: Record = { + 'session-root': artifact('session-root', undefined, 'durable root'), + 'child-a': artifact('child-a', sid('session-root'), 'durable child'), + } + const flushed: SessionId[] = [] + const api = await buildApi(stored, [node('child-a')], { + sessions: { + get: id => durable[id] === undefined ? undefined : { id }, + flush: async (session) => { + const artifactAfterFlush = durable[session.id] + if (artifactAfterFlush === undefined) throw new Error('unexpected session') + flushed.push(session.id) + stored[session.id] = artifactAfterFlush + return true + }, + }, + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(flushed).toEqual([sid('session-root'), sid('child-a')]) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root') + expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child') + }) + + it('reads a cold artifact without asking the live-session store to flush', async () => { + const flush = vi.fn(async () => true) + const root = artifact('session-root') + const api = await buildApi({ 'session-root': root }, [], { + sessions: { + get: () => undefined, + flush, + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const files = unzipSync(await responseBytes(response)) + expect(flush).not.toHaveBeenCalled() + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + it('answers 404 for a missing root session', async () => { const api = await buildApi({}) const response = await toFetchHandler(api).fetch( From 192840e198e410bfc23f6cbd607d75e17324090d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:14:10 +0800 Subject: [PATCH 09/25] fix(session-export): propagate download cancellation Only the root raw-artifact read received the request signal. Lineage discovery and descendant reads could continue after disconnect, response-body cancellation did not stop the producer, and the root error boundary converted an abort rejection into an ordinary HTTP 500. Combine request and response-consumer cancellation into the ZIP producer signal, forward it through every cancellable read, check it around the attachment seam, and terminate fflate exactly once when production stops. The pre-stream boundary now rethrows the original abort instead of translating it. Regression tests cover signal propagation, exact cancellation identity at the HTTP boundary, and a reader cancellation interrupting an in-flight descendant read; the bilingual host contract records these lifecycle semantics. --- ...026-08-10-web-session-log-export.i18n.yaml | 4 +- .../2026-08-10-web-session-log-export.md | 2 +- .../2026-08-10-web-session-log-export.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 2 + packages/host/apiproxy/src/session-export.ts | 46 ++++-- .../apiproxy/tests/session-export.spec.ts | 136 +++++++++++++++++- 9 files changed, 176 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index b781cad5a1..e2588d1529 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: a290eb7043833b66a217476a86c985ec8c9f33de -2026-08-10-web-session-log-export.zh.md: b02fff598048250fe747f124ad86cd98352aa28f +2026-08-10-web-session-log-export.md: 25aac00e1a3bd11d3f2a95770e520f53c348f8c2 +2026-08-10-web-session-log-export.zh.md: d00d3437ef53994d513a35829b9f5215ae5df417 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index a290eb7043..25aac00e1a 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -11,7 +11,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision - **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root), and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. -- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. +- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage and persistence reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. - **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. - The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index b02fff5980..d00d3437ef 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -11,7 +11,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 - **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本),且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 -- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘与持久化读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 - **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 - 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 16ffe6245c..bfafdbbc97 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: 1d9790beba0b72bec15c3e6f3b35a4d1f0f67d61 -README.zh.md: 478088f1167edd4e3c2b55c33e914e85349d2bba +README.md: c7b655816099d786a08ecfae6d794f35f2a9c8e3 +README.zh.md: 7577eb025fb84ac40de206e3ed780d92c2ab237a diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 1d9790beba..c7b6558160 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. -Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 478088f116..7577eb025f 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 -会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 86a85c18ae..bfa119b4cb 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -3506,7 +3506,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro try { await flushLiveSessionLog(deps, request.sessionId, signal) root = await deps.sessionPersistence.readRaw(request.sessionId, signal) + signal.throwIfAborted() } catch { + signal.throwIfAborted() // Backend read failure: answer 500 without echoing the error, which // may carry absolute host paths into the browser error bar. return new Response('session log export failed to read the stored artifact', { status: 500 }) diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts index 992bfc0ea7..be9bdd41ee 100644 --- a/packages/host/apiproxy/src/session-export.ts +++ b/packages/host/apiproxy/src/session-export.ts @@ -8,7 +8,9 @@ * every file is byte-identical to the backend's durable artifact or attachment * store and self-describing through its own header line or media type. Before * each live session's artifact read, the SessionStore flush barrier makes the - * current in-memory log durable; cold sessions need no barrier. + * current in-memory log durable; cold sessions need no barrier. Request abort + * and response-consumer cancellation share one producer signal and terminate + * the active compressor. * Compression runs on the host with fflate's streaming Zip API, so the archive * bytes are produced incrementally and the host never holds the whole archive * in one buffer; production yields to the consumer whenever the response queue @@ -206,7 +208,7 @@ export function sessionLogZipFilename(sessionId: string): string { * missing-session path can answer cleanly before streaming starts). * @param sessionId - the root session id. * @param includeDescendants - whether to include every subagent descendant. - * @param signal - optional cancellation for read work. + * @param signal - optional cancellation forwarded to lineage and persistence reads. * @returns the export entries in zip order. */ export async function* sessionLogZipEntries( @@ -233,7 +235,8 @@ export async function* sessionLogZipEntries( if (seen.has(id)) continue seen.add(id) await flushLiveSessionLog(deps, id, signal) - const raw = await deps.sessionPersistence.readRaw(id) + const raw = await deps.sessionPersistence.readRaw(id, signal) + signal?.throwIfAborted() if (raw === undefined) { throw new Error(`subagent "${id}" has no stored log artifact`) } @@ -245,12 +248,14 @@ export async function* sessionLogZipEntries( yield* collect(node.descendants) } } - const lineage = await deps.sessionQuery.traceSession(sessionId) + const lineage = await deps.sessionQuery.traceSession(sessionId, signal) + signal?.throwIfAborted() yield* collect(lineage.descendants) } for (const ref of media.values()) { signal?.throwIfAborted() const stored = await deps.attachments.readImage(ref) + signal?.throwIfAborted() yield { path: mediaEntryPath(ref), data: stored.data } } } @@ -335,7 +340,7 @@ async function pushArtifactChunks( * @param root - the already-read root artifact (first zip entry). * @param sessionId - the root session id. * @param includeDescendants - whether to include every subagent descendant. - * @param signal - optional cancellation for read work. + * @param signal - request cancellation combined with response-consumer cancellation. * @returns the zip byte stream. */ export function streamSessionLogZip( @@ -343,15 +348,24 @@ export function streamSessionLogZip( root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, - signal?: AbortSignal, + signal: AbortSignal, ): ReadableStream { + const consumerAbort = new AbortController() + const producerSignal = AbortSignal.any([signal, consumerAbort.signal]) + let zip: Zip | undefined + let zipTerminated = false + const terminateZip = (): void => { + if (zip === undefined || zipTerminated) return + zipTerminated = true + zip.terminate() + } return new ReadableStream({ start(controller) { // fflate invokes the callback synchronously per compressed chunk, so a // single push can enqueue ahead of a slow consumer; pushArtifactChunks // yields between chunks once the queue is over-full, bounding the // accumulation to the queue high-water mark plus one push. - const zip = new Zip((error, data, final) => { + const archive = new Zip((error, data, final) => { /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */ if (error) { controller.error(error) @@ -361,25 +375,33 @@ export function streamSessionLogZip( if (data.byteLength > 0) controller.enqueue(data) if (final) controller.close() }) + zip = archive void (async () => { try { - for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) { + for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) { const deflate = new ZipDeflate(entry.path, { level: 6 }) - zip.add(deflate) + archive.add(deflate) if ('content' in entry) { - await pushArtifactChunks(deflate, entry.content, controller, signal) + await pushArtifactChunks(deflate, entry.content, controller, producerSignal) } else { - await pushBinaryChunks(deflate, entry.data, controller, signal) + await pushBinaryChunks(deflate, entry.data, controller, producerSignal) } } - zip.end() + archive.end() } catch (error) { // A mid-stream failure (missing descendant, cancellation, read // error) must fail the download rather than ship a truncated archive. /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */ + terminateZip() controller.error(error instanceof Error ? error : new Error(String(error))) } })() }, + cancel(reason) { + consumerAbort.abort( + reason instanceof Error ? reason : new Error('session log export stream cancelled'), + ) + terminateZip() + }, }) } diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 968a7529c5..3aa9b1a9d2 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -65,6 +65,14 @@ async function buildApi( get(id: SessionId): { readonly id: SessionId } | undefined flush(session: { readonly id: SessionId }): Promise } + readRaw?: (id: SessionId, signal?: AbortSignal) => Promise + traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{ + target: { header: SessionHeader; live: boolean; persisted: boolean } + ancestors: readonly SessionLineageNode[] + complete: boolean + root: { header: SessionHeader; live: boolean; persisted: boolean } + descendants: readonly SessionLineageNode[] + }> } = {}, ) { const ctx = new Context() @@ -73,22 +81,22 @@ async function buildApi( const persistence = services.persistence ?? true if (query) { ctx.provide('sessionQuery', { - traceSession: async () => ({ + traceSession: services.traceSession ?? (async () => ({ target: { header: header('session-root'), live: false, persisted: true }, ancestors: [], complete: true, root: { header: header('session-root'), live: false, persisted: true }, descendants, - }), + })), } as never) } if (persistence) { ctx.provide('sessionPersistence', { supportsRawArtifacts: persistence !== 'unsupported', - readRaw: async (id: SessionId) => { + readRaw: services.readRaw ?? (async (id: SessionId) => { if (persistence === 'throw') throw new Error('/host/private/session.jsonl') return artifacts[id] - }, + }), } as never) } if (services.attachments !== false) { @@ -322,6 +330,126 @@ describe('session.export download endpoint', () => { expect(body).not.toContain('/host/private/') }) + it('forwards one request signal through root, lineage, and descendant reads', async () => { + const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = [] + const traces: AbortSignal[] = [] + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id, signal) => { + reads.push({ id, signal }) + return id === sid('session-root') + ? artifact('session-root') + : artifact('child-a', sid('session-root')) + }, + traceSession: async (_id, signal) => { + if (signal !== undefined) traces.push(signal) + return { + target: { header: header('session-root'), live: false, persisted: true }, + ancestors: [], + complete: true, + root: { header: header('session-root'), live: false, persisted: true }, + descendants: [node('child-a')], + } + }, + }) + const controller = new AbortController() + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + controller.signal, + ) + await response.arrayBuffer() + const producerSignal = traces[0] + if (producerSignal === undefined) throw new Error('missing lineage signal') + expect(reads[0]).toEqual({ id: sid('session-root'), signal: controller.signal }) + expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal }) + const cancellation = new Error('request cancelled after response') + controller.abort(cancellation) + expect(producerSignal.aborted).toBe(true) + expect(producerSignal.reason).toBe(cancellation) + }) + + it('preserves request cancellation instead of translating it to HTTP 500', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const controller = new AbortController() + const cancellation = new Error('request cancelled') + controller.abort(cancellation) + await expect(api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + controller.signal, + )).rejects.toBe(cancellation) + }) + + it('aborts descendant work and terminates ZIP production when its reader cancels', async () => { + let reportDescendantStarted!: (signal: AbortSignal) => void + const descendantStarted = new Promise((resolve) => { + reportDescendantStarted = resolve + }) + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id, signal) => { + if (id === sid('session-root')) return artifact('session-root') + if (signal === undefined) throw new Error('missing descendant signal') + reportDescendantStarted(signal) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error) + }, { once: true }) + }) + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + new AbortController().signal, + ) + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('missing response body') + const descendantSignal = await descendantStarted + const cancellation = new Error('download consumer left') + await reader.cancel(cancellation) + expect(descendantSignal.aborted).toBe(true) + expect(descendantSignal.reason).toBe(cancellation) + }) + + it('uses a stable Error reason when its reader cancels without one', async () => { + let reportDescendantStarted!: (signal: AbortSignal) => void + const descendantStarted = new Promise((resolve) => { + reportDescendantStarted = resolve + }) + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id, signal) => { + if (id === sid('session-root')) return artifact('session-root') + if (signal === undefined) throw new Error('missing descendant signal') + reportDescendantStarted(signal) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error) + }, { once: true }) + }) + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + new AbortController().signal, + ) + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('missing response body') + const descendantSignal = await descendantStarted + await reader.cancel() + expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled')) + }) + + it('normalizes a non-Error descendant failure before erroring the stream', async () => { + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id) => { + if (id === sid('session-root')) return artifact('session-root') + throw 'descendant read failed' + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + new AbortController().signal, + ) + await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed')) + }) + it('includes media objects referenced by the root log under media/.', async () => { const root = artifact('session-root', undefined, [ '{"type":"session","version":0,"id":"session-root","createdAt":1000}', From 1419671f3fe5edbba76cb910735070d077f80750 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:18:34 +0800 Subject: [PATCH 10/25] fix(session-export): wait for response pull capacity The ZIP loop checked desiredSize only after a push and responded to an overfull queue with setTimeout(0). A timer turn does not mean the consumer drained anything, so a slow or disconnected client still allowed the producer to enqueue the complete compressed archive while later artifact and attachment reads ran eagerly. Give the ReadableStream a 64 KiB byte queuing strategy and block the single producer on a pull-released capacity gate whenever desiredSize is non-positive. Cancellation wakes that gate through the existing producer signal; synchronous fflate output is therefore bounded to the queue high-water mark plus one input push. A regression test exhausts timer turns without consuming and proves the next media entry remains unread until response pulling begins, and the bilingual contracts now describe the real bound. --- ...026-08-10-web-session-log-export.i18n.yaml | 4 +- .../2026-08-10-web-session-log-export.md | 2 +- .../2026-08-10-web-session-log-export.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/session-export.ts | 90 ++++++++++++++----- .../apiproxy/tests/session-export.spec.ts | 34 ++++++- 8 files changed, 107 insertions(+), 33 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index e2588d1529..937bb0df03 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 25aac00e1a3bd11d3f2a95770e520f53c348f8c2 -2026-08-10-web-session-log-export.zh.md: d00d3437ef53994d513a35829b9f5215ae5df417 +2026-08-10-web-session-log-export.md: 4568b5cf0e84a7efdf6e0e86d7e5955a2430f0f8 +2026-08-10-web-session-log-export.zh.md: 842330e30cc0a46579a823f80306ce88d6df1552 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 25aac00e1a..4568b5cf0e 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -10,7 +10,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision -- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root), and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. +- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. - **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage and persistence reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. - **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. - The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index d00d3437ef..842330e30c 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -10,7 +10,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 -- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本),且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 +- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 - **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘与持久化读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 - **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 - 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告。 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index bfafdbbc97..a0c5921be4 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: c7b655816099d786a08ecfae6d794f35f2a9c8e3 -README.zh.md: 7577eb025fb84ac40de206e3ed780d92c2ab237a +README.md: 3c301b48cc92762fc1dff07a9442a1d48e66b1cc +README.zh.md: 79240941348783070b955162325fccf25c33aaae diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index c7b6558160..3c301b48cc 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. -Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 7577eb025f..7924094134 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 -会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts index be9bdd41ee..621bbfe6f5 100644 --- a/packages/host/apiproxy/src/session-export.ts +++ b/packages/host/apiproxy/src/session-export.ts @@ -13,10 +13,9 @@ * the active compressor. * Compression runs on the host with fflate's streaming Zip API, so the archive * bytes are produced incrementally and the host never holds the whole archive - * in one buffer; production yields to the consumer whenever the response queue - * fills past its high-water mark, so a slow consumer bounds the accumulation - * instead of piling up the whole archive (fflate's callback is synchronous — - * this drain point is the only backpressure available). + * in one buffer; production waits for consumer pull whenever the response queue + * reaches its byte high-water mark, so a slow consumer bounds accumulation to + * the configured queue plus one synchronous fflate push. * @module */ @@ -266,30 +265,66 @@ const PUSH_CHUNK_CODE_UNITS = 1 << 16 /** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */ const PUSH_CHUNK_BYTES = 1 << 16 +/** Byte capacity retained by the response stream before ZIP production waits for pull. */ +const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16 + +/** One producer waiter released only when ReadableStream pull restores capacity. */ +class ResponseCapacityGate { + private releasePending: (() => void) | undefined + + /** + * Wait until the response queue has positive byte capacity or cancellation wins. + * @param controller - response controller whose desired size owns capacity. + * @param signal - combined request/consumer cancellation. + */ + async wait( + controller: ReadableStreamDefaultController, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted() + if (controller.desiredSize === null || controller.desiredSize > 0) return + await new Promise((resolve) => { + const release = (): void => { + this.releasePending = undefined + signal.removeEventListener('abort', release) + resolve() + } + this.releasePending = release + signal.addEventListener('abort', release, { once: true }) + }) + signal.throwIfAborted() + } + + /** Release the current producer waiter after a consumer pull. */ + pulled(): void { + this.releasePending?.() + } +} + /** * Push one media object's bytes into a deflate stream in bounded chunks, - * yielding to a slow consumer between chunks like the artifact path does. + * waiting for consumer capacity between chunks like the artifact path does. * @param deflate - the zip entry's deflate stream. * @param data - the stored image bytes. - * @param signal - optional cancellation; throws when aborted. + * @param controller - response queue controller. + * @param capacity - pull-driven response-capacity gate. + * @param signal - cancellation; throws when aborted. */ async function pushBinaryChunks( deflate: ZipDeflate, data: Uint8Array, controller: ReadableStreamDefaultController, - signal?: AbortSignal, + capacity: ResponseCapacityGate, + signal: AbortSignal, ): Promise { let offset = 0 do { - signal?.throwIfAborted() + signal.throwIfAborted() const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength) const finalChunk = end >= data.byteLength deflate.push(data.subarray(offset, end), finalChunk) offset = end - /* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */ - if (controller.desiredSize !== null && controller.desiredSize < 0) { - await new Promise(resolve => setTimeout(resolve, 0)) - } + await capacity.wait(controller, signal) } while (offset < data.byteLength) } @@ -299,19 +334,22 @@ async function pushBinaryChunks( * re-encodes as U+FFFD and would silently corrupt the exported artifact). * @param deflate - the zip entry's deflate stream. * @param content - the artifact text verbatim. - * @param signal - optional cancellation; throws when aborted. + * @param controller - response queue controller. + * @param capacity - pull-driven response-capacity gate. + * @param signal - cancellation; throws when aborted. */ async function pushArtifactChunks( deflate: ZipDeflate, content: string, controller: ReadableStreamDefaultController, - signal?: AbortSignal, + capacity: ResponseCapacityGate, + signal: AbortSignal, ): Promise { const encoder = new TextEncoder() let offset = 0 let finalChunk: boolean do { - signal?.throwIfAborted() + signal.throwIfAborted() let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length) if (end < content.length && end - offset > 1) { // Back off one code unit when the boundary lands inside a surrogate @@ -322,10 +360,7 @@ async function pushArtifactChunks( finalChunk = end >= content.length deflate.push(encoder.encode(content.slice(offset, end)), finalChunk) offset = end - /* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */ - if (controller.desiredSize !== null && controller.desiredSize < 0) { - await new Promise(resolve => setTimeout(resolve, 0)) - } + await capacity.wait(controller, signal) } while (!finalChunk) } @@ -354,6 +389,7 @@ export function streamSessionLogZip( const producerSignal = AbortSignal.any([signal, consumerAbort.signal]) let zip: Zip | undefined let zipTerminated = false + const capacity = new ResponseCapacityGate() const terminateZip = (): void => { if (zip === undefined || zipTerminated) return zipTerminated = true @@ -362,9 +398,9 @@ export function streamSessionLogZip( return new ReadableStream({ start(controller) { // fflate invokes the callback synchronously per compressed chunk, so a - // single push can enqueue ahead of a slow consumer; pushArtifactChunks - // yields between chunks once the queue is over-full, bounding the - // accumulation to the queue high-water mark plus one push. + // single push can enqueue ahead of a slow consumer; the capacity gate + // waits for pull between pushes once the byte queue is full, bounding + // accumulation to the queue high-water mark plus one synchronous push. const archive = new Zip((error, data, final) => { /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */ if (error) { @@ -382,9 +418,9 @@ export function streamSessionLogZip( const deflate = new ZipDeflate(entry.path, { level: 6 }) archive.add(deflate) if ('content' in entry) { - await pushArtifactChunks(deflate, entry.content, controller, producerSignal) + await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal) } else { - await pushBinaryChunks(deflate, entry.data, controller, producerSignal) + await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal) } } archive.end() @@ -397,11 +433,17 @@ export function streamSessionLogZip( } })() }, + pull() { + capacity.pulled() + }, cancel(reason) { consumerAbort.abort( reason instanceof Error ? reason : new Error('session log export stream cancelled'), ) terminateZip() }, + }, { + highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES, + size: chunk => chunk.byteLength, }) } diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 3aa9b1a9d2..5a124db86c 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -5,7 +5,8 @@ * root → 404, missing descendant → errored stream). */ -import { describe, expect, it } from 'vitest' +import { randomBytes } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { unzipSync, strFromU8 } from 'fflate' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' @@ -286,6 +287,37 @@ describe('session.export download endpoint', () => { expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) }) + it('waits for response pull capacity before reading the next archive entry', async () => { + const root = artifact('session-root', undefined, [ + imageEventLine('after-root'), + randomBytes(512 * 1024).toString('base64'), + ].join('\n')) + let imageReads = 0 + const api = await buildApi({ 'session-root': root }, [], { + attachments: async (ref) => { + imageReads += 1 + return storedImage(String(ref.attachmentId), ref.mediaType) + }, + }) + vi.useFakeTimers() + let response: Response | undefined + try { + response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + // Exhausting timer turns must not advance a producer whose byte queue is + // full; only a consumer pull can release it. + await vi.runAllTimersAsync() + expect(imageReads).toBe(0) + } finally { + vi.useRealTimers() + } + if (response === undefined) throw new Error('missing export response') + const files = unzipSync(await responseBytes(response)) + expect(imageReads).toBe(1) + expect(files['media/after-root.png']).toEqual(storedImage('after-root').data) + }) + it('exports an empty artifact as an empty zip entry', async () => { const root = { ...artifact('session-root'), content: '' } const api = await buildApi({ 'session-root': root }) From 8a2a22db846a05c874aaa8b57df58c29ebe97107 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:24:14 +0800 Subject: [PATCH 11/25] fix(apiproxy): configure session export compression Session-log ZIP entries always used DEFLATE level 6 even though compression level is a deployment tradeoff: CPU-constrained hosts may prefer low latency while bandwidth-constrained hosts may prefer smaller archives. A hardcoded level also violated the repository rule that deployment-varying plugin choices live in validated Config. Add sessionExportCompressionLevel to ApiProxyService.Config as an integer 0-9 with default 6, resolve the same default once for direct createApiProxy callers, and pass the required level into the streaming module. Tests prove schema defaulting and rejection as well as a level-0 versus level-9 archive-size difference with identical extracted content. The generated config catalog, bilingual gateway README, and feature note document the knob and its tradeoff. --- ...026-08-10-web-session-log-export.i18n.yaml | 4 +- .../2026-08-10-web-session-log-export.md | 2 +- .../2026-08-10-web-session-log-export.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 10 ++++- docs/config-catalog.zh.md | 10 ++++- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/src/api-proxy.ts | 15 +++++++- packages/host/apiproxy/src/index.ts | 15 +++++++- packages/host/apiproxy/src/session-export.ts | 10 ++++- .../apiproxy/tests/session-export.spec.ts | 38 ++++++++++++++++++- 13 files changed, 101 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index 937bb0df03..d2c1bbe0ec 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 4568b5cf0e84a7efdf6e0e86d7e5955a2430f0f8 -2026-08-10-web-session-log-export.zh.md: 842330e30cc0a46579a823f80306ce88d6df1552 +2026-08-10-web-session-log-export.md: 68b164578263efe0f0a879e4e4acbdf8a9f945c8 +2026-08-10-web-session-log-export.zh.md: c3172bc3353073d50747485fbe0220e777a7c146 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 4568b5cf0e..68b1645782 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -10,7 +10,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision -- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. +- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. - **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage and persistence reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. - **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. - The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 842330e30c..c3172bc335 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -10,7 +10,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 -- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 +- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 - **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘与持久化读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 - **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 - 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index b9fe09779c..a11582ee18 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: fbd88a9137e61f97209f2f136f46a95806da399d -config-catalog.zh.md: 8794093955984c7c12dabeed742a4717c16741a5 +config-catalog.md: f48f95531e5cf2005e3160da2fca8a00cfa80124 +config-catalog.zh.md: f25d432849afb4f4ca034ef3b365006c72c2c900 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index fbd88a9137..f48f95531e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -647,7 +647,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** Gateway plugin config for native Host integration. */ +/** Gateway plugin configuration. */ export interface Config { /** * Whether this deployment can hand paths to a native desktop opener — @@ -657,10 +657,16 @@ export interface Config { * container whose DISPLAY points nowhere a user can see. */ nativeOpen?: boolean + /** + * DEFLATE level for every session-log ZIP entry: `0` stores without + * compression, `1` favors CPU/latency, and `9` favors archive size. + * @default 6 + */ + sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 } ``` -Source: [`packages/host/apiproxy/src/index.ts:37`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:41`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8794093955..f25d432849 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -649,7 +649,7 @@ export interface Config { 需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** Gateway plugin config for native Host integration. */ +/** Gateway plugin configuration. */ export interface Config { /** * Whether this deployment can hand paths to a native desktop opener — @@ -659,10 +659,16 @@ export interface Config { * container whose DISPLAY points nowhere a user can see. */ nativeOpen?: boolean + /** + * DEFLATE level for every session-log ZIP entry: `0` stores without + * compression, `1` favors CPU/latency, and `9` favors archive size. + * @default 6 + */ + sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 } ``` -来源:[`packages/host/apiproxy/src/index.ts:37`](../packages/host/apiproxy/src/index.ts) +来源:[`packages/host/apiproxy/src/index.ts:41`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index a0c5921be4..e020c6f93f 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 packages/host/apiproxy/README.md -README.md: 3c301b48cc92762fc1dff07a9442a1d48e66b1cc -README.zh.md: 79240941348783070b955162325fccf25c33aaae +README.md: 2101c785a613477c04ecbfec6a39a0f403af40ef +README.zh.md: 3ba37967ff88ca89911017945aeed857e4b4ff19 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 3c301b48cc..2101c785a6 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. +The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?, sessionExportCompressionLevel?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. ## The shared Agent default (`agent-default-model` Settings section) @@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. -Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 7924094134..3ba37967ff 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 +所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?, sessionExportCompressionLevel?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 ## 共享 Agent 默认值(`agent-default-model` Settings 分节) @@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 -会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index bfa119b4cb..26bc2d7f6b 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -43,11 +43,13 @@ import type { WorkspaceId, WorkspaceView, } from './api/index.ts' import { + DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, sessionLogExportDeps, sessionLogZipFilename, streamSessionLogZip, type SessionLogExportReady, + type SessionLogCompressionLevel, } from './session-export.ts' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' import { @@ -544,6 +546,8 @@ export interface ApiProxyDefaults { openPath?: (path: string, signal: AbortSignal) => Promise /** Native text-editor handoff; injectable for settings-document tests. */ openTextFile?: (path: string, signal: AbortSignal) => Promise + /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */ + sessionExportCompressionLevel?: SessionLogCompressionLevel /** * Whether handing a path to the native opener can work at all — the * `hasDocument` capability the preset roster reports, and the switch @@ -989,6 +993,8 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie * @returns the ApiProxy implementation. */ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { + const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel + ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL /** The seed model each create/resume declares; re-read so it never goes stale. */ const agentOptions = (): AgentOptions => { const { provider, model } = defaults.defaultModelSelection() @@ -3517,7 +3523,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return new Response('session not found', { status: 404 }) } return new Response( - streamSessionLogZip(ready, root, request.sessionId, request.includeDescendants === true, signal), + streamSessionLogZip( + ready, + root, + request.sessionId, + request.includeDescendants === true, + sessionExportCompressionLevel, + signal, + ), { headers: { 'content-type': 'application/zip', diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 6bb062dcad..a59549d318 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -17,6 +17,10 @@ import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent-default-model' import type { ApiProxy } from './api/index.ts' import { createApiProxy } from './api-proxy.ts' +import { + DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, + type SessionLogCompressionLevel, +} from './session-export.ts' export type * from './api/index.ts' export { RpcId } from './api/rpc.ts' @@ -33,7 +37,7 @@ declare module '@deepseek-ai/cordis' { } } -/** Gateway plugin config for native Host integration. */ +/** Gateway plugin configuration. */ export interface Config { /** * Whether this deployment can hand paths to a native desktop opener — @@ -43,6 +47,12 @@ export interface Config { * container whose DISPLAY points nowhere a user can see. */ nativeOpen?: boolean + /** + * DEFLATE level for every session-log ZIP entry: `0` stores without + * compression, `1` favors CPU/latency, and `9` favors archive size. + * @default 6 + */ + sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 } /** @@ -58,6 +68,8 @@ export class ApiProxyService extends Service implements ApiProxy { static Config: z = z.object({ nativeOpen: z.boolean(), + sessionExportCompressionLevel: z.number().step(1).min(0).max(9) + .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z, }) readonly sessions: ApiProxy['sessions'] @@ -82,6 +94,7 @@ export class ApiProxyService extends Service implements ApiProxy { saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection), cwd: process.cwd(), ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean }, + sessionExportCompressionLevel: config.sessionExportCompressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, }) this.sessions = api.sessions this.subagents = api.subagents diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts index 621bbfe6f5..14c9049ae8 100644 --- a/packages/host/apiproxy/src/session-export.ts +++ b/packages/host/apiproxy/src/session-export.ts @@ -26,6 +26,12 @@ import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-s import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session' import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +/** Valid fflate DEFLATE levels accepted by session-log export. */ +export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 + +/** Balanced default used when a direct createApiProxy caller omits deployment config. */ +export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6 + /** The services a session-log export needs (the live-session store is optional). */ export interface SessionLogExportDeps { readonly sessionQuery: SessionQueryService | undefined @@ -375,6 +381,7 @@ async function pushArtifactChunks( * @param root - the already-read root artifact (first zip entry). * @param sessionId - the root session id. * @param includeDescendants - whether to include every subagent descendant. + * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry. * @param signal - request cancellation combined with response-consumer cancellation. * @returns the zip byte stream. */ @@ -383,6 +390,7 @@ export function streamSessionLogZip( root: SessionRawArtifact, sessionId: SessionId, includeDescendants: boolean, + compressionLevel: SessionLogCompressionLevel, signal: AbortSignal, ): ReadableStream { const consumerAbort = new AbortController() @@ -415,7 +423,7 @@ export function streamSessionLogZip( void (async () => { try { for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) { - const deflate = new ZipDeflate(entry.path, { level: 6 }) + const deflate = new ZipDeflate(entry.path, { level: compressionLevel }) archive.add(deflate) if ('content' in entry) { await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal) diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 5a124db86c..1932b54501 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -14,8 +14,7 @@ import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' -import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' +import ApiProxyService, { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' const sid = (id: string): SessionId => id as SessionId @@ -74,6 +73,7 @@ async function buildApi( root: { header: SessionHeader; live: boolean; persisted: boolean } descendants: readonly SessionLineageNode[] }> + compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 } = {}, ) { const ctx = new Context() @@ -115,6 +115,9 @@ async function buildApi( return createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', + ...services.compressionLevel === undefined + ? {} + : { sessionExportCompressionLevel: services.compressionLevel }, }) } @@ -122,6 +125,19 @@ async function responseBytes(response: Response): Promise { return new Uint8Array(await response.arrayBuffer()) } +describe('session export compression config', () => { + it('defaults to level 6 and rejects values outside the integer 0-9 range', () => { + expect(ApiProxyService.Config({})).toEqual({ sessionExportCompressionLevel: 6 }) + expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 })) + .toEqual({ sessionExportCompressionLevel: 0 }) + expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 })) + .toEqual({ sessionExportCompressionLevel: 9 }) + for (const value of [-1, 10, 1.5]) { + expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow() + } + }) +}) + describe('session.export download endpoint', () => { it('streams a ZIP with the root artifact verbatim under its original filename', async () => { const api = await buildApi({ 'session-root': artifact('session-root') }) @@ -136,6 +152,24 @@ describe('session.export download endpoint', () => { expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) }) + it('uses the resolved compression level for ZIP entries', async () => { + const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024)) + const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 }) + const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 }) + const stored = await storedApi.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const compressed = await compressedApi.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const storedBytes = await responseBytes(stored) + const compressedBytes = await responseBytes(compressed) + expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength) + expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + it('includes descendant artifacts under subagents// when requested', async () => { const api = await buildApi({ 'session-root': artifact('session-root'), From d1aae98895a8576b16df8302b820feb0f8fc3a90 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:46:25 +0800 Subject: [PATCH 12/25] docs(connection): describe native export handoff accurately The fixture comment still said the Trajectory action used window.fetch after the implementation moved to a temporary download anchor. That wording implied client-side response handling and buffering which the browser-download design deliberately avoids.\n\nDescribe the actual native download-manager handoff while retaining the important contract: the fixture download stub only satisfies the host type and is unreachable through fixture dispatch. --- packages/client/connection/src/client/fixture.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 05706a3788..a26db9c473 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2835,8 +2835,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return Promise.resolve({ accepted: true }) }, // Satisfies the ApiProxy contract type only: the browser export button - // fetches GET /api/session.export directly (window.fetch), so this stub is - // never reached through the fixture's dispatch. + // hands GET /api/session.export to the native download manager, so this + // stub is never reached through the fixture's dispatch. downloads: { sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), }, From 5703ae356ee976d64f113a5ff88c6070e3b48858 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:46:40 +0800 Subject: [PATCH 13/25] refactor(apiproxy): resolve export compression once The Cordis schema supplies the normal plugin default, while createApiProxy also owns the fallback required by direct programmatic callers. Repeating the same nullish fallback in ApiProxyService created a third defaulting site without adding a distinct invariant.\n\nPass the validated config value through unchanged and leave createApiProxy as the single implementation boundary that turns an optional request value into the required compression specification. --- packages/host/apiproxy/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index a59549d318..07fb742551 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -94,7 +94,7 @@ export class ApiProxyService extends Service implements ApiProxy { saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection), cwd: process.cwd(), ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean }, - sessionExportCompressionLevel: config.sessionExportCompressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, + sessionExportCompressionLevel: config.sessionExportCompressionLevel, }) this.sessions = api.sessions this.subagents = api.subagents From 8d6372858454b34cee2a189fa116362741174141 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:47:13 +0800 Subject: [PATCH 14/25] fix(apiproxy): name root export preparation failures The pre-stream error boundary covers both the live-session flush barrier and the persistence read, but its response attributed every failure to reading storage. A flush failure therefore produced a misleading diagnostic even though the response correctly withheld private backend details.\n\nUse preparation as the shared operation name and cover the flush-failure path explicitly. Both preparation stages now retain one stable, path-safe HTTP 500 without pretending to identify the failing stage. --- packages/host/apiproxy/src/api-proxy.ts | 6 +++--- .../host/apiproxy/tests/session-export.spec.ts | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 26bc2d7f6b..2474a05df0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -3515,9 +3515,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro signal.throwIfAborted() } catch { signal.throwIfAborted() - // Backend read failure: answer 500 without echoing the error, which - // may carry absolute host paths into the browser error bar. - return new Response('session log export failed to read the stored artifact', { status: 500 }) + // Root preparation failure: answer 500 without echoing the error, + // which may carry absolute host paths into the browser error bar. + return new Response('session log export failed to prepare the stored artifact', { status: 500 }) } if (root === undefined) { return new Response('session not found', { status: 404 }) diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 1932b54501..92cdaa4db2 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -392,7 +392,23 @@ describe('session.export download endpoint', () => { ) expect(response.status).toBe(500) const body = await response.text() - expect(body).toBe('session log export failed to read the stored artifact') + expect(body).toBe('session log export failed to prepare the stored artifact') + expect(body).not.toContain('/host/private/') + }) + + it('answers the private-error-safe 500 when the live root flush fails', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }, [], { + sessions: { + get: id => ({ id }), + flush: async () => { throw new Error('/host/private/flush-state') }, + }, + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).toBe('session log export failed to prepare the stored artifact') expect(body).not.toContain('/host/private/') }) From 5e067fa7fe5b3e3f03937cbc471a44e075f74de8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:47:29 +0800 Subject: [PATCH 15/25] docs(apiproxy): state the export queue bound exactly The response stream uses a fixed 64 KiB byte high-water mark; no deployment setting controls it. Calling that queue configured incorrectly suggested another tuning surface and obscured the concrete memory bound.\n\nName the fixed capacity directly while preserving the separate bound of one synchronous fflate push beyond the queued bytes. --- packages/host/apiproxy/src/session-export.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts index 14c9049ae8..2dcadf7502 100644 --- a/packages/host/apiproxy/src/session-export.ts +++ b/packages/host/apiproxy/src/session-export.ts @@ -15,7 +15,7 @@ * bytes are produced incrementally and the host never holds the whole archive * in one buffer; production waits for consumer pull whenever the response queue * reaches its byte high-water mark, so a slow consumer bounds accumulation to - * the configured queue plus one synchronous fflate push. + * the fixed 64 KiB response queue plus one synchronous fflate push. * @module */ From c10d74ba95ce8d8ccf51ea2bd80a94776df0ed21 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:52:24 +0800 Subject: [PATCH 16/25] fix(apiproxy): cancel attachment reads during export Response-consumer cancellation already stopped lineage reads, persistence reads, and ZIP production, but the final attachment phase called readImage without the producer signal. A slow or stalled attachment backend could therefore keep working after the browser abandoned the download and prevent the producer from settling.\n\nExtend the attachment read seam with optional cancellation, forward it through the local backend into Node's filesystem read, and preserve the abort reason rather than wrapping it as a storage failure. The exporter now passes its combined request/consumer signal to every attachment read.\n\nCover both ownership boundaries: the local-store test proves filesystem forwarding and cancellation identity, while the assembled export test cancels a reader during a pending attachment provider call. Regenerate the Cordis API catalog and paired documentation so implementers can rely on the new contract. --- ...026-08-10-web-session-log-export.i18n.yaml | 4 +-- .../2026-08-10-web-session-log-export.md | 2 +- .../2026-08-10-web-session-log-export.zh.md | 2 +- docs/subsystems/attachment.i18n.yaml | 4 +-- docs/subsystems/attachment.md | 4 ++- docs/subsystems/attachment.zh.md | 4 ++- .../attachment-local/README.i18n.yaml | 4 +-- .../attachment/attachment-local/README.md | 2 +- .../attachment/attachment-local/README.zh.md | 2 +- .../attachment/attachment-local/src/index.ts | 4 +-- .../attachment/attachment-local/src/store.ts | 14 ++++++-- .../attachment-local/tests/store.spec.ts | 27 +++++++++++++- .../attachment/attachment/README.i18n.yaml | 4 +-- packages/attachment/attachment/README.md | 2 +- packages/attachment/attachment/README.zh.md | 2 +- packages/attachment/attachment/src/index.ts | 4 ++- packages/host/apiproxy/src/session-export.ts | 4 +-- .../apiproxy/tests/session-export.spec.ts | 35 ++++++++++++++++++- .../tool-cordis/src/api-catalog.ts | 4 +-- 19 files changed, 101 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index d2c1bbe0ec..e24e2894a5 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 68b164578263efe0f0a879e4e4acbdf8a9f945c8 -2026-08-10-web-session-log-export.zh.md: c3172bc3353073d50747485fbe0220e777a7c146 +2026-08-10-web-session-log-export.md: 8fa62b877df1be55de2c373d4281672881dc2b9d +2026-08-10-web-session-log-export.zh.md: 3040dda992492187245bfe92d29bc0812ef01ef2 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 68b1645782..8fa62b877d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -11,7 +11,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision - **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. -- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage and persistence reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. +- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. - **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. - The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index c3172bc335..3040dda992 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -11,7 +11,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 - **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 -- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘与持久化读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 - **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 - 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告。 diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml index d53f337679..330f2db253 100644 --- a/docs/subsystems/attachment.i18n.yaml +++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md -attachment.md: bfc1a54107c75b442f6b5b61fb705852ab4213db -attachment.zh.md: 4da600390ea111e9b2f640c51ab786ca0505db6e +attachment.md: ff7f14ceae8d4f8055d5cfd4367373729dc5ecbc +attachment.zh.md: d7a9527788588d5504fdeffd8ae7849b0f8b1378 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md index bfc1a54107..ff7f14ceae 100644 --- a/docs/subsystems/attachment.md +++ b/docs/subsystems/attachment.md @@ -104,9 +104,11 @@ abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend read and verification work. * @returns the verified bytes and canonical reference. + * @throws the signal reason when aborted, or a storage error when verification fails. */ -abstract readImage(ref: ImageAttachmentRef): Promise +abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise ``` Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts) diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md index 4da600390e..d7a9527788 100644 --- a/docs/subsystems/attachment.zh.md +++ b/docs/subsystems/attachment.zh.md @@ -104,9 +104,11 @@ abstract saveImage(input: SaveImageAttachment): Promise /** * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend read and verification work. * @returns the verified bytes and canonical reference. + * @throws the signal reason when aborted, or a storage error when verification fails. */ -abstract readImage(ref: ImageAttachmentRef): Promise +abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise ``` Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts) diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index daa65c2d38..d875ce6519 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.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 packages/attachment/attachment-local/README.md -README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f -README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa +README.md: ba0b9efb2cf51bfef671020bed4a2c16f6ee0119 +README.zh.md: 8e2474357a0dbb5e8834a3b25de7a977827a29e3 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 80001b29b3..ba0b9efb2c 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. -`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. +`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. ## Model Experience diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index c3b95ace06..8e2474357a 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -4,7 +4,7 @@ 这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 -`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。 +`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 ## 模型体验 diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 3d67041ea4..ceb46f415d 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -68,8 +68,8 @@ export class LocalAttachmentStore extends AttachmentStore { return saveImageFile(this.root, input, this.imageLimits) } - async readImage(ref: ImageAttachmentRef): Promise { - return readImageFile(this.root, ref) + async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise { + return readImageFile(this.root, ref, signal) } } diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index d77f2be375..8e4e83c1c9 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -197,22 +197,32 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li * Read and verify one content-addressed image. * @param root - absolute `DSH_HOME/attachments/v1` root. * @param ref - reference recorded in the session log. + * @param signal - optional cancellation for filesystem and verification work. * @returns verified bytes and reference. + * @throws the signal reason when aborted, or an AttachmentError when verification fails. */ -export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise { +export async function readImageFile( + root: string, + ref: ImageAttachmentRef, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted() const sha256 = ensureReference(ref) let data: Uint8Array try { - data = new Uint8Array(await readFile(objectPath(root, sha256))) + data = new Uint8Array(await readFile(objectPath(root, sha256), { signal })) } catch (error) { + signal?.throwIfAborted() if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND') throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error }) } + signal?.throwIfAborted() if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') // The digest proves these are the exact bytes admission fully decoded, so // the read path only re-derives the header fields (no raster decode, no // per-request pixel amplification on history replay). const metadata = await probeImage(data) + signal?.throwIfAborted() if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) { throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT') diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index bd2adb4c55..ec3551abb2 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -9,12 +9,23 @@ import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import { readImageFile, saveImageFile } from '../src/store.ts' -const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] })) +const fsControl = vi.hoisted(() => ({ + readSignals: [] as AbortSignal[], + syncedDirectories: [] as string[], +})) vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + readFile(...args: Parameters): ReturnType { + const options = args[1] + if (typeof options === 'object' && options !== null) { + const signal = (options as { signal?: AbortSignal }).signal + if (signal !== undefined) fsControl.readSignals.push(signal) + } + return actual.readFile(...args) + }, async open(...args: Parameters): ReturnType { if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0])) return actual.open(...args) @@ -130,6 +141,20 @@ describe('local attachment store', () => { await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) + it('forwards read cancellation to the filesystem and preserves its reason', async () => { + const storageRoot = await root() + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const controller = new AbortController() + fsControl.readSignals.length = 0 + + await expect(readImageFile(storageRoot, ref, controller.signal)).resolves.toEqual({ ref, data: PNG }) + expect(fsControl.readSignals).toEqual([controller.signal]) + + const cancellation = new Error('attachment read cancelled') + controller.abort(cancellation) + await expect(readImageFile(storageRoot, ref, controller.signal)).rejects.toBe(cancellation) + }) + it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => { const storageRoot = await root() await expect(saveImageFile(storageRoot, { diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index c75c93eb1a..bebd5ee4e7 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.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 packages/attachment/attachment/README.md -README.md: 4f450316294e554396adb9a8454051a08d9befd3 -README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890 +README.md: baeeca0cf939f1a3d4608769b362d532507b90f5 +README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 4f45031629..baeeca0cf9 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. ## Model Experience diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index fe51b0003c..238b90794c 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -4,7 +4,7 @@ 持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。 +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 ## 模型体验 diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index d2dc2dbd86..1bfb1ea119 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -52,9 +52,11 @@ export abstract class AttachmentStore extends Service { /** * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. + * @param signal - optional cancellation for backend read and verification work. * @returns the verified bytes and canonical reference. + * @throws the signal reason when aborted, or a storage error when verification fails. */ - abstract readImage(ref: ImageAttachmentRef): Promise + abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise } export default AttachmentStore diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts index 2dcadf7502..c42c603e85 100644 --- a/packages/host/apiproxy/src/session-export.ts +++ b/packages/host/apiproxy/src/session-export.ts @@ -213,7 +213,7 @@ export function sessionLogZipFilename(sessionId: string): string { * missing-session path can answer cleanly before streaming starts). * @param sessionId - the root session id. * @param includeDescendants - whether to include every subagent descendant. - * @param signal - optional cancellation forwarded to lineage and persistence reads. + * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads. * @returns the export entries in zip order. */ export async function* sessionLogZipEntries( @@ -259,7 +259,7 @@ export async function* sessionLogZipEntries( } for (const ref of media.values()) { signal?.throwIfAborted() - const stored = await deps.attachments.readImage(ref) + const stored = await deps.attachments.readImage(ref, signal) signal?.throwIfAborted() yield { path: mediaEntryPath(ref), data: stored.data } } diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index 92cdaa4db2..a766c4b4eb 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -60,7 +60,7 @@ async function buildApi( services: { query?: boolean persistence?: boolean | 'throw' | 'unsupported' - attachments?: boolean | ((ref: ImageAttachmentRef) => Promise>) + attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise>) sessions?: { get(id: SessionId): { readonly id: SessionId } | undefined flush(session: { readonly id: SessionId }): Promise @@ -490,6 +490,39 @@ describe('session.export download endpoint', () => { expect(descendantSignal.reason).toBe(cancellation) }) + it('aborts attachment reads when its reader cancels', async () => { + let reportAttachmentStarted!: (signal: AbortSignal) => void + const attachmentStarted = new Promise((resolve) => { + reportAttachmentStarted = resolve + }) + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('slow-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }, [], { + attachments: async (_ref, signal) => { + if (signal === undefined) throw new Error('missing attachment signal') + reportAttachmentStarted(signal) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error) + }, { once: true }) + }) + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('missing response body') + const attachmentSignal = await attachmentStarted + const cancellation = new Error('download consumer left during attachment read') + await reader.cancel(cancellation) + expect(attachmentSignal.aborted).toBe(true) + expect(attachmentSignal.reason).toBe(cancellation) + }) + it('uses a stable Error reason when its reader cancels without one', async () => { let reportDescendantStarted!: (signal: AbortSignal) => void const descendantStarted = new Promise((resolve) => { diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index a2ac4cd8f9..94835f96b5 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -237,8 +237,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', }, { - signature: 'abstract readImage(ref: ImageAttachmentRef): Promise', - jsDoc: '/**\n * Read one image and verify that bytes still match the recorded reference.\n * @param ref - durable reference from the session log.\n * @returns the verified bytes and canonical reference.\n */', + signature: 'abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Read one image and verify that bytes still match the recorded reference.\n * @param ref - durable reference from the session log.\n * @param signal - optional cancellation for backend read and verification work.\n * @returns the verified bytes and canonical reference.\n * @throws the signal reason when aborted, or a storage error when verification fails.\n */', }, ], }, From 59e98ca8dd97c1bc4a1d61ccd183e387749797a0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 16:54:57 +0800 Subject: [PATCH 17/25] docs: add contribution guide --- CONTRIBUTING.md | 21 +++++++++++++++++++++ README.i18n.yaml | 4 ++-- README.md | 2 ++ README.zh.md | 2 ++ 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..c7098ac353 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,21 @@ +# Contributing + +Thank you for your interest in contributing to DeepSeek Harness! + +We deeply believe in the power of open source communities, and that belief has shaped this project from the very beginning. + +DeepSeek Harness is still at an early stage and under active development. We are sorry that we cannot accept external pull requests at the moment. However, contributing code to this repository is far from the only way to help. There are many other ways to get involved: + +- Identify and report issues or bugs in GitHub Discussions. + - Upvote discussions that you would like to bring to the team's attention. We are a very small team and may not be able to reply to every post, but we monitor them and consider them when allocating resources. +- Contribute to the ecosystem: + - Create a plugin that excites you and share it with others. + - Associate your GitHub project with the `dsh-plugin` topic to help others discover your plugin. + - Write blog posts and how-to guides about DeepSeek Harness. + - Answer questions and help other members of the community. + +DeepSeek Harness is designed to be deeply customizable. We do not believe that packages in the official repository are inherently more important than packages created by the community. You may consider this repository an idea, an official showcase, and a source of inspiration, but not a mandate from us. + +We have already seen exciting projects emerge from the community, and we hope to see the ecosystem continue to grow in its own directions. + +Into the unknown. diff --git a/README.i18n.yaml b/README.i18n.yaml index 552fb03a25..3b138ed5dd 100644 --- a/README.i18n.yaml +++ b/README.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 README.md -README.md: b2d84672275a4ca996b6ea596c104abfaa52432a -README.zh.md: 747a3c88bc129ad5dd24f5fa150a1661c4db0b11 +README.md: efe171d624be21488daabe20b839e715e8f4673a +README.zh.md: b12765179e55d0d3e134e1bb3c3991f4a63477a0 diff --git a/README.md b/README.md index b2d8467227..efe171d624 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,5 @@ DeepSeek Harness is currently in internal testing. [BSD 3-Clause](LICENSE) Third-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). + +Read [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository. diff --git a/README.zh.md b/README.zh.md index 747a3c88bc..b12765179e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -92,3 +92,5 @@ DeepSeek Harness 目前处于内测阶段。 [BSD 3-Clause](LICENSE) 第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。 + +向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。 From 00c466437033206f631d6ba60569cae9e7d336c9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 17:15:20 +0800 Subject: [PATCH 18/25] test: refresh translation prompt snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index d4ab49c13c..dc5e3a7fc1 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 从源码运行\n\n克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 从源码运行\n\n克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" }, { "role": "user", From 2f4bf08798421d66807f8260fe85da0f5c7090c1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 17:55:48 +0800 Subject: [PATCH 19/25] docs: add Chinese contribution guide --- CONTRIBUTING.i18n.yaml | 6 ++++++ CONTRIBUTING.md | 2 ++ CONTRIBUTING.zh.md | 23 +++++++++++++++++++++++ docs/i18n/README.i18n.yaml | 4 ++-- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- scripts/translation-pairing.spec.ts | 4 ++++ scripts/translation-pairing.ts | 2 ++ 8 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 CONTRIBUTING.i18n.yaml create mode 100644 CONTRIBUTING.zh.md diff --git a/CONTRIBUTING.i18n.yaml b/CONTRIBUTING.i18n.yaml new file mode 100644 index 0000000000..3470fe5932 --- /dev/null +++ b/CONTRIBUTING.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 CONTRIBUTING.md +CONTRIBUTING.md: 9dd90e8e032eb80384047e18d02e07cec6138ee2 +CONTRIBUTING.zh.md: 7d4e8849ab01af8407ccc6e85135dbb37ee2fc32 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7098ac353..9dd90e8e03 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,7 @@ # Contributing +English | [中文](CONTRIBUTING.zh.md) + Thank you for your interest in contributing to DeepSeek Harness! We deeply believe in the power of open source communities, and that belief has shaped this project from the very beginning. diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md new file mode 100644 index 0000000000..7d4e8849ab --- /dev/null +++ b/CONTRIBUTING.zh.md @@ -0,0 +1,23 @@ +# 贡献 + +[English](CONTRIBUTING.md) | 中文 + +感谢你有兴趣为 DeepSeek Harness 作出贡献! + +我们深信开源社区的力量,这份信念从项目最初就塑造着 DeepSeek Harness。 + +DeepSeek Harness 仍处于早期阶段,并在积极开发中。很抱歉,我们目前无法接受外部 PR(Pull Request)。但贡献代码远不是帮助这个仓库的唯一方式。你还可以通过许多其他方式参与其中: + +- 在 GitHub Discussions 中发现并报告问题或 bug。 + - 为你希望引起团队关注的讨论投票。我们的团队规模很小,可能无法回复每个帖子,但我们会持续关注,并在分配资源时将这些讨论纳入考虑。 +- 为生态系统作出贡献: + - 创建令你感兴趣的插件,并分享给其他人。 + - 为你的 GitHub 项目添加 `dsh-plugin` topic,帮助其他人发现你的插件。 + - 撰写有关 DeepSeek Harness 的博客文章和操作指南。 + - 回答问题并帮助其他社区成员。 + +DeepSeek Harness 的设计支持深度定制。我们不认为官方仓库中的包在本质上比社区创建的包更重要。你可以将这个仓库视为一种思路、一个官方展示和一项灵感来源,而不是我们要求社区遵循的方向。 + +我们已经看到社区中涌现出令人期待的项目,也希望生态系统继续沿着自己的方向发展。 + +向未知进发。 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 087e9e9dfe..334be9e2cf 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.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 docs/i18n/README.md -README.md: 9875eb0c9924daa0b519923e9aac8a67de8cda61 -README.zh.md: eed73226dffd9bc1f6af7b21af5b0b77363878e2 +README.md: 23400801426f77dae5136406cd747dbe4b06a4c5 +README.zh.md: fe3cc7b5a5403fc9cf0c9ce536178d4fa7581e3c diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 9875eb0c99..2340080142 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -41,7 +41,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co ## Scope and exclusions -**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source. +**Scope**: the root CONTRIBUTING document, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source. Generated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index eed73226df..fe3cc7b5a5 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -41,7 +41,7 @@ ## 范围与排除 -**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。 +**范围**:根目录 CONTRIBUTING 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。 有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。 diff --git a/scripts/translation-pairing.spec.ts b/scripts/translation-pairing.spec.ts index b5b2a1d5a7..a77dd98248 100644 --- a/scripts/translation-pairing.spec.ts +++ b/scripts/translation-pairing.spec.ts @@ -182,6 +182,9 @@ describe('translation pairing records', () => { describe('translation scope discovery', () => { it.each([ 'README.md', + 'CONTRIBUTING.md', + 'CONTRIBUTING.zh.md', + 'CONTRIBUTING.i18n.yaml', 'apps/cli/README.md', 'future/subtree/readme.md', 'packages/example/README.zh.md', @@ -195,6 +198,7 @@ describe('translation scope discovery', () => { it.each([ 'packages/example/guide.md', + 'packages/example/CONTRIBUTING.md', 'examples/tutorial.md', 'website/reference.md', 'packages/example/README.txt', diff --git a/scripts/translation-pairing.ts b/scripts/translation-pairing.ts index 01a9208427..ef94d84e2c 100644 --- a/scripts/translation-pairing.ts +++ b/scripts/translation-pairing.ts @@ -125,6 +125,7 @@ export interface TranslationPairingManifest { } const README_ARTIFACT = /(?:^|\/)readme(?:\.md|\.zh\.md|\.i18n\.yaml)$/i +const ROOT_CONTRIBUTING_ARTIFACT = /^contributing(?:\.md|\.zh\.md|\.i18n\.yaml)$/i const NON_SOURCE_DIRECTORIES = new Set([ 'node_modules', 'lib', @@ -179,6 +180,7 @@ function isTranslationSourceExcluded(file: string): boolean { export function isTranslationScopeFile(file: string): boolean { return !file.startsWith('.agents/notes/archived/') && !isTranslationSourceExcluded(file) && (README_ARTIFACT.test(file) + || ROOT_CONTRIBUTING_ARTIFACT.test(file) || file.startsWith('.agents/notes/') || file.startsWith('docs/') || file.startsWith('python/')) From b52ddb2887ef4ee6b850fd0ed594861a251060bd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:59:07 +0800 Subject: [PATCH 20/25] fix(apiproxy): omit an unresolved compression option ApiProxyDefaults uses an exact optional property, so passing config.sessionExportCompressionLevel directly made the service object carry an explicit undefined that is not assignable to the resolved request shape. The full host build caught this distinction after the redundant fallback was removed.\n\nConditionally omit the property when Cordis has not supplied a value. Direct createApiProxy callers still receive the implementation-owned default, while configured plugin values pass through without introducing another defaulting site. --- packages/host/apiproxy/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 07fb742551..ca0cf0329b 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -94,7 +94,9 @@ export class ApiProxyService extends Service implements ApiProxy { saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection), cwd: process.cwd(), ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean }, - sessionExportCompressionLevel: config.sessionExportCompressionLevel, + ...(config.sessionExportCompressionLevel === undefined + ? {} + : { sessionExportCompressionLevel: config.sessionExportCompressionLevel }), }) this.sessions = api.sessions this.subagents = api.subagents From a11081ac840b4029134d9368fb8e49391bfbc4b3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 18:09:16 +0800 Subject: [PATCH 21/25] test: refresh contribution pairing snapshot --- .../translation-prompt-v4/request-response.expected.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index dc5e3a7fc1..0160c63cf2 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -24,11 +24,11 @@ }, { "role": "user", - "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" + "content": "# Bilingual documentation\n\nEnglish | [中文](README.zh.md)\n\nThis repo's documentation is read by people and agents both inside and outside the company, so every document in scope is maintained in English and Simplified Chinese. This page defines the pairing contract, checks, scope, and exclusions; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. Routine agent work follows the lightweight path in [docs/AGENTS.md](../AGENTS.md); the extended [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow is available only through explicit user invocation.\n\n## The pairing contract\n\n- **Both languages carry equal authority.** A document may be authored and reviewed in either language first — a Chinese-first Agent Note is as legitimate as an English-first one — and the counterpart is translated from it. Neither file outranks the other; what binds them is that they must say the same thing.\n- **A pair is three sibling files.** The English `foo.md`, the Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`, all in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. Pairs merge whole: a PR never lands one language without the other two files.\n- **The consistency record.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last time the two were confirmed to say the same thing:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. Routine work makes that patch directly; when the user explicitly invokes the extended workflow, `pnpm run gen-translation-brief ` can instead assemble the update at the narrowest safely aligned granularity and `--apply` can splice a code-fence-only change after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write ` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).\n\n When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its required switchers and structural signature. The Chinese file must retain its English backlink; an authored English source must retain its Chinese link, while a listed generated English source is exempt. Any structure the driver cannot verify remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.\n- **Language switcher.** The Chinese file always links back immediately after its H1 heading with `[English](foo.md) | 中文`. An authored English file reciprocates there with `English | [中文](foo.zh.md)`; a listed generated English source omits that line so it remains byte-identical to generator output.\n- **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).\n\n## The gate: verify-translation-pairing\n\n`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically:\n\n1. Every document in scope has a complete pair. README discovery is case-insensitive on the basename, so `missions/readme.md` is in scope alongside the other documentation roots.\n2. Every pair artifact that exists at all is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), the Chinese side and every authored English source carry their language switchers (listed generated English sources are exempt), and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.\n3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. Frozen Agent Notes under `.agents/notes/archived/` are outside this evolving gate; their dedicated verifier requires and seals the complete existing triplet instead.\n\nSource-oriented code gates consume an exact `.zh.md` fence sequence as a derivative of its unsuffixed sibling instead of compiling or manifesting the same code twice. The sequence must match in length, order, fence kind, and byte-exact body; otherwise both copies remain independently checked and the pairing gate reports the structural mismatch.\n\n`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.\n\n`pnpm run verify-translation-pairing ` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.\n\nThe practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart directly in one terminology-guided pass and re-records the pair with `--write `**, exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.\n\nThe gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and Markdown structure; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.\n\n## Scope and exclusions\n\n**Scope**: the root CONTRIBUTING document, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.\n\nGenerated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.\n\n**Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md) — generated without a reviewed Chinese counterpart, so both website locales project the English source.\n- `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.\n- `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.\n- [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.\n- `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.\n\n**Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.\n\n## Division of labor\n\nRoutine counterparts are updated directly by the working agent in one shot and one pass after it loads [terminology.md](terminology.md); it does not invoke a translation skill, generate a briefing, run a separate translation-review pass, or delegate to a subagent. The extended [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) workflow retains those heavier mechanisms for explicit user invocation. The gate checks pair completeness, recorded hashes, the Chinese backlink and authored-source switcher (with the documented generated-source exception), and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the committed template (terminology injected; the template carries its own calibrated rules) into either direction and parses the three-section response, while `verify-translation-prompt` exercises both render directions and the checked-in example in `doc-sync`.\n" }, { "role": "assistant", - "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" + "content": "# 双语文档\n\n[English](README.md) | 中文\n\n本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此范围内的每篇文档都以英文和简体中文维护。本页定义配对约定、检查、范围与排除规则;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。agent 的日常工作遵循 [docs/AGENTS.md](../AGENTS.md) 中的轻量路径;扩展版 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流仅在用户显式调用时可用。\n\n## 配对约定\n\n- **两种语言同权。** 一篇文档可以先用任一语言撰写和评审(先写中文的 Agent Note 与先写英文的一样正当),另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。\n- **一对文档是三个同目录文件。** 英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`,都在同一目录。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。配对必须整体合并:PR(Pull Request)永远不会只带一种语言而缺其余两个文件。\n- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash:\n\n ```yaml\n foo.md: 3f786850e387550fdab836ed7e6dc881de23001b\n foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b\n ```\n\n 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob,使垃圾回收无法让已记录的恢复指针失效。因此记录的 hash 能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。日常工作会直接完成这份修补;用户显式调用扩展工作流时,可改由 `pnpm run gen-translation-brief ` 以能安全对齐的最窄粒度汇集这次更新,并由 `--apply` 在结构校验后拼接仅涉及围栏代码块的改动([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。\n\n 当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob,且合并后的配对仍保留必需的语言切换行和结构签名时,组合出一份新记录。中文文件必须保留指向英文的反向链接;普通撰写的英文源必须保留指向中文的链接,而清单内的生成英文源不作此要求。任何合并驱动无法验证的结构都保留为普通冲突;`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。\n- **语言切换行。** 中文文件一律在 H1 标题后立即以 `[English](foo.md) | 中文` 链回英文。普通撰写的英文文件在同一位置以 `English | [中文](foo.zh.md)` 互链;清单内的生成英文源省略此行,以便与生成器输出逐字节一致。\n- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。\n\n## 门禁:verify-translation-pairing\n\n`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份约定:\n\n1. 范围内的每篇文档都有完整配对。发现 README 时,basename 不区分大小写,因此 `missions/readme.md` 与其他文档根一样属于范围。\n2. 任何已存在的配对产物都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、中文侧和所有普通撰写的英文源都带语言切换行(清单内的生成英文源除外)、结构签名按序一致:标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。\n3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。`.agents/notes/archived/` 下冻结的 Agent Note 不受这个持续演进的门禁约束;专用校验器会要求其现有的三个配对文件完整,并将其封存。\n\n面向源码的代码门禁会把精确的 `.zh.md` 围栏序列视为其无后缀兄弟文件的派生内容,而不会再次编译相同代码或在 manifest 中重复登记。该序列必须在长度、顺序、围栏类型和按字节精确的正文上一致;否则两份副本仍会独立受检,配对门禁也会报告结构不匹配。\n\n`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。\n\n`pnpm run verify-translation-pairing ` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。\n\n这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 在术语指导下直接一次完成对侧文件的更新,并用 `--write ` 重新记录配对**,与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。\n\n门禁的限制很明确:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与 Markdown 结构;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分约定由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。\n\n## 范围与排除\n\n**范围**:根目录 CONTRIBUTING 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。\n\n有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。\n\n**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):\n\n- [cordis-api/inherited.md](../cordis-api/inherited.md):该生成文档没有经评审的中文对侧,因此网站的两个 locale 都投影英文源文件。\n- `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。\n- `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。\n- [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。\n- `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。\n\n**统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。\n\n## 分工\n\n日常更新对侧文件时,负责处理的 agent 会先加载 [terminology.md](terminology.md),再直接一次性更新且只处理一遍;它不会调用翻译 skill(技能)、生成简报、执行单独的翻译评审轮次,也不会委派给 subagent。扩展版 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 工作流保留这些较重的机制,仅供用户显式调用。门禁负责检查配对是否完整、记录的 hash、中文反向链接和普通撰写源的切换行(生成源按本文规则例外),以及本文列出的结构签名;翻译质量、术语和签名未涵盖的结构要求仍由评审把关。提示词约定也有可执行实现:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 会把仓库内置的模板(注入术语表;模板自带经人工校准的规则)渲染为英译中或中译英两个方向的提示词,并解析三段式响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向与仓库内示例。\n" }, { "role": "user", From 5931afaf87f4db69bc15a6220efacdf9e8e2587a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:10:28 +0800 Subject: [PATCH 22/25] docs(session-persistence): bind raw capability to its reader The abstract capability flag forces every backend to state whether it owns per-session raw artifacts, but TypeScript cannot express that a true flag requires replacing the concrete unsupported default. Without an implementer-facing obligation, a backend could advertise support and then fail with a contradictory unsupported diagnostic on first use.\n\nDocument the required pairing at the capability declaration. Keep readRaw concrete so backends that correctly report false inherit one fail-loud implementation instead of duplicating rejection code. --- packages/session/session-persistence/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 07a8819ca1..d579dc46d9 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -95,7 +95,10 @@ export abstract class SessionPersistence extends Service { */ abstract locate(meta: SessionHeader): SessionLocation | undefined - /** Whether this backend exposes one verbatim raw artifact per session. */ + /** + * Whether this backend exposes one verbatim raw artifact per session. + * A backend that declares `true` must override {@link readRaw}. + */ abstract readonly supportsRawArtifacts: boolean /** From 8a6c3736e4adb7da1db40eb6d3ab26a1ee0762a2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 18:16:29 +0800 Subject: [PATCH 23/25] docs: separate contribution links from license --- README.i18n.yaml | 4 ++-- README.md | 2 ++ README.zh.md | 2 ++ .../translation-prompt-v4/request-response.expected.json | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 3b138ed5dd..a7f0956a0a 100644 --- a/README.i18n.yaml +++ b/README.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 README.md -README.md: efe171d624be21488daabe20b839e715e8f4673a -README.zh.md: b12765179e55d0d3e134e1bb3c3991f4a63477a0 +README.md: 9c19dfec19cba6f1364e4f9d5734af49675d68c2 +README.zh.md: 31d83ede854e9f0dfbbba1f8ce1094d043f6d829 diff --git a/README.md b/README.md index efe171d624..9c19dfec19 100644 --- a/README.md +++ b/README.md @@ -89,4 +89,6 @@ DeepSeek Harness is currently in internal testing. Third-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +## Contributing + Read [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository. diff --git a/README.zh.md b/README.zh.md index b12765179e..31d83ede85 100644 --- a/README.zh.md +++ b/README.zh.md @@ -93,4 +93,6 @@ DeepSeek Harness 目前处于内测阶段。 第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。 +## 参与贡献 + 向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 0160c63cf2..3e7308ee97 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n\n## Contributing\n\nRead [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 从源码运行\n\n克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 从源码运行\n\n克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n\n## 参与贡献\n\n向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n" }, { "role": "user", From d29a8261e0efb454e41084f7f08ef0b6ee9fc45e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:24:17 +0800 Subject: [PATCH 24/25] fix(vendor): realign the rescope log anchor PR #2239 removed the old in-memory activation entry and renumbered the local-modification log so Cordis source publication is item 16 and the rescope is item 17. It updated vendor/README.md but left this exact edit expecting the rescope before an item 18, so the current master post-state matched neither side and pnpm run hygiene failed. Treat item 16 as the pre-rescope anchor and append item 17 in the replacement. The forward edit now produces the checked-in ordering, while reversing it removes only the rescope entry and preserves the independent Cordis publication note. Verified with the rescope-vendor unit suite, pnpm run rescope-vendor:check, pnpm run hygiene, and git diff --cached --check. --- scripts/rescope-vendor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 45f936e58b..d6f84efd63 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -242,8 +242,8 @@ const EXACT_EDITS: readonly ExactEdit[] = [ { id: 'vendor-readme-local-modification-log', file: 'vendor/README.md', - find: '\n18. **`cordis/package.json` publishes `src`**', - replace: '\n17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n18. **`cordis/package.json` publishes `src`**', + find: '\n16. **`cordis/package.json` publishes `src`**', + replace: '\n16. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match.\n17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).', expect: 1, }, { From 8e3418e925ecc4ca7992b64d924201969b370863 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 11 Aug 2026 18:41:10 +0800 Subject: [PATCH 25/25] test(workflow): avoid unbound view builder reference --- .../client/ui-workflow-run/tests/workflow-run.spec.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx index 308de0a2a8..667eabe903 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.spec.tsx @@ -227,13 +227,12 @@ describe('workflow-run Conversation Definition', () => { const unrelated = matched(at(3, 'turn/start', { turn: 1 }), 'update') expect(workflowRunDefinition.update(updateContext, unrelated)).toBe(state) expect(workflowRunDefinition.target).toBe('chat') - const buildViewNode = workflowRunDefinition.buildViewNode - if (buildViewNode === undefined) throw new Error('expected workflow Chat view builder') - expect(buildViewNode({ + expect(workflowRunDefinition.buildViewNode?.({ ...updateContext, matches: [], start: undefined, })).toBeNull() - const directNode = buildViewNode(updateContext) as ChatConversationViewNode | null + const directNode = workflowRunDefinition.buildViewNode?.(updateContext) as ChatConversationViewNode | null | undefined if (directNode === null) throw new Error('expected direct workflow Chat node') + if (directNode === undefined) throw new Error('expected workflow Chat view builder') expect(directNode.kind).toBe('workflow-run') expect((directNode.data as WorkflowRunChatData).status).toBe('running') })