From 757c7212dd16053b45c3002cb499ae9bd8e6e53e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 9 Aug 2026 22:32:44 +0800 Subject: [PATCH 01/56] Refine documentation homepage positioning --- docs/user/index.i18n.yaml | 4 ++-- docs/user/index.md | 14 +++++++------- docs/user/index.zh.md | 14 +++++++------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/user/index.i18n.yaml b/docs/user/index.i18n.yaml index 619c549462..670a3c8033 100644 --- a/docs/user/index.i18n.yaml +++ b/docs/user/index.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/user/index.md -index.md: e9a1f03785c7472c47550ec59ea0165d28d3d9a6 -index.zh.md: aba42d79d36e7f5c2e6833f609e48f7b2a79f813 +index.md: bf656e391273b828abe67bc0741f2efe7de957c6 +index.zh.md: 1d4a45a1423604d60ec9fba76431f9b7f0844044 diff --git a/docs/user/index.md b/docs/user/index.md index e9a1f03785..bf656e3912 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -2,7 +2,7 @@ layout: home hero: name: DeepSeek Harness - text: Plugin-based agent development framework + text: Plugin-based Coding Agent tagline: Built on the Cordis microkernel; everything is a plugin actions: - theme: brand @@ -12,12 +12,12 @@ hero: text: Develop plugins link: /en/develop/basic/ features: - - title: Plugin architecture - details: Built on the Cordis plugin system. Every capability is registered by a plugin, takes effect when loaded, and is reverted when unloaded. - - title: Configuration as composition - details: One cordis.yml determines the agent's complete capability set. Change a model or add a tool by editing configuration. - - title: Ready to use - details: Includes LLM calls, file access, Bash execution, subagent delegation, and the rest of the core toolchain. Copy a template to get started. + - title: Microkernel + details: The kernel manages plugin lifecycles, events, and dependencies without containing product-specific capabilities. + - title: Plugin-first + details: Models, tools, sessions, and storage are provided by plugins that work together through events. + - title: Composable + details: Select, replace, or extend capabilities through configuration without modifying the Agent Loop. --- # DeepSeek Harness diff --git a/docs/user/index.zh.md b/docs/user/index.zh.md index aba42d79d3..1d4a45a142 100644 --- a/docs/user/index.zh.md +++ b/docs/user/index.zh.md @@ -2,7 +2,7 @@ layout: home hero: name: DeepSeek Harness - text: 插件化 agent(智能体)开发框架 + text: 插件化 Coding Agent tagline: 基于 Cordis 微内核,一切皆插件 actions: - theme: brand @@ -12,12 +12,12 @@ hero: text: 开发插件 link: /develop/basic/ features: - - title: 插件化架构 - details: 基于 Cordis 插件系统,所有能力通过插件注册,加载即生效、卸载即还原。 - - title: 配置即组合 - details: 一个 cordis.yml 决定整个 agent 的能力组合——换模型、加工具,只需改一行配置。 - - title: 开箱即用 - details: 内置 LLM(大语言模型)调用、文件读写、Bash 执行、subagent 委派等完整工具链,复制模板即可运行。 + - title: 微内核 + details: 内核只负责插件生命周期、事件通信和依赖管理,不包含具体业务能力。 + - title: 插件化 + details: 模型、工具、会话和存储都由插件提供,并通过事件协作。 + - title: 自由组合 + details: 通过配置选择、替换或扩展能力,不需要修改 Agent Loop。 --- # DeepSeek Harness From 8de6df19d9763491b0a2c47f026909853ae9ed2a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Mon, 10 Aug 2026 18:37:30 +0800 Subject: [PATCH 02/56] 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 03/56] 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 04/56] 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 05/56] 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 06/56] 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 07/56] 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 08/56] 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 09/56] 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 10/56] 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 11/56] 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 12/56] 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 e611e825b10ffffe419c39038c5e1c2cb647003e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 11 Aug 2026 17:01:29 +0800 Subject: [PATCH 13/56] feat(web): align attachment display with DeepSeek Chat via ui-attachment atoms Single-click original preview in the composer rail and chat history; remove control inside the thumbnail, revealed on hover/focus (always on touch); hidden-scrollbar rail overflow paged by edge arrows with wheel panning and end-reveal on add; image-intake rejections and prompt failures announce as a transient top-center toast instead of inline strips. The attachment atoms move to a new zero-cordis package @deepseek-ai/dsh-client-ui-attachment (rail, message gallery, lightbox), seeded as a platform module; the toast is a ui-primitives atom. Strings arrive as label props bridged from the conversation dictionary. --- ...web-attachment-display-alignment.i18n.yaml | 6 + ...-08-11-web-attachment-display-alignment.md | 31 ++++ ...-11-web-attachment-display-alignment.zh.md | 31 ++++ apps/web/tests/image-display.snapshot.ts | 6 +- apps/web/vite.config.ts | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + .../client/ui-attachment/README.i18n.yaml | 6 + packages/client/ui-attachment/README.md | 26 +++ packages/client/ui-attachment/README.zh.md | 26 +++ packages/client/ui-attachment/package.json | 49 ++++++ .../src/AttachmentRail.module.css | 112 +++++++++++++ .../ui-attachment/src/AttachmentRail.tsx | 152 ++++++++++++++++++ .../src}/ImageLightbox.module.css | 0 .../src}/ImageLightbox.tsx | 29 +++- .../src}/MessageImage.module.css | 2 +- .../src}/MessageImage.tsx | 54 +++++-- .../client/ui-attachment/src/css-modules.d.ts | 6 + packages/client/ui-attachment/src/index.ts | 14 ++ .../client/ui-attachment/src/invariant.ts | 31 ++++ .../tests/attachment-rail.spec.tsx | 128 +++++++++++++++ .../tests/image-lightbox.spec.tsx | 50 ++++++ .../ui-attachment/tests/invariant.spec.ts | 12 ++ .../tests/message-image.spec.tsx | 103 ++++++++++++ packages/client/ui-attachment/tsconfig.json | 21 +++ .../client/ui-attachment/tsdown.config.ts | 31 ++++ packages/client/ui-conversation/package.json | 2 + .../src/client/chat/AssistantMarkdown.tsx | 5 +- .../src/client/chat/MessageItem.tsx | 5 +- .../src/client/image-labels.ts | 48 ++++++ .../ui-conversation/src/client/locales.ts | 12 +- .../src/client/skeleton/InputBar.module.css | 73 +-------- .../src/client/skeleton/InputBar.tsx | 109 +++++++------ .../tests/image-labels.spec.tsx | 82 ++++++++++ .../ui-conversation/tests/input-bar.spec.tsx | 53 +++++- .../tests/message-image.spec.tsx | 81 ---------- packages/client/ui-conversation/tsconfig.json | 3 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../client/ui-primitives/src/Toast.module.css | 58 +++++++ packages/client/ui-primitives/src/Toast.tsx | 37 +++++ packages/client/ui-primitives/src/index.ts | 1 + .../client/ui-primitives/tests/toast.spec.tsx | 40 +++++ packages/client/web/package.json | 1 + packages/client/web/src/platform.ts | 1 + packages/client/web/src/seed.ts | 2 + packages/client/web/tsconfig.json | 3 + pnpm-lock.yaml | 43 ++++- .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 56 files changed, 1366 insertions(+), 251 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md create mode 100644 .agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md create mode 100644 packages/client/ui-attachment/README.i18n.yaml create mode 100644 packages/client/ui-attachment/README.md create mode 100644 packages/client/ui-attachment/README.zh.md create mode 100644 packages/client/ui-attachment/package.json create mode 100644 packages/client/ui-attachment/src/AttachmentRail.module.css create mode 100644 packages/client/ui-attachment/src/AttachmentRail.tsx rename packages/client/{ui-conversation/src/client/skeleton => ui-attachment/src}/ImageLightbox.module.css (100%) rename packages/client/{ui-conversation/src/client/skeleton => ui-attachment/src}/ImageLightbox.tsx (54%) rename packages/client/{ui-conversation/src/client/chat => ui-attachment/src}/MessageImage.module.css (97%) rename packages/client/{ui-conversation/src/client/chat => ui-attachment/src}/MessageImage.tsx (52%) create mode 100644 packages/client/ui-attachment/src/css-modules.d.ts create mode 100644 packages/client/ui-attachment/src/index.ts create mode 100644 packages/client/ui-attachment/src/invariant.ts create mode 100644 packages/client/ui-attachment/tests/attachment-rail.spec.tsx create mode 100644 packages/client/ui-attachment/tests/image-lightbox.spec.tsx create mode 100644 packages/client/ui-attachment/tests/invariant.spec.ts create mode 100644 packages/client/ui-attachment/tests/message-image.spec.tsx create mode 100644 packages/client/ui-attachment/tsconfig.json create mode 100644 packages/client/ui-attachment/tsdown.config.ts create mode 100644 packages/client/ui-conversation/src/client/image-labels.ts create mode 100644 packages/client/ui-conversation/tests/image-labels.spec.tsx delete mode 100644 packages/client/ui-conversation/tests/message-image.spec.tsx create mode 100644 packages/client/ui-primitives/src/Toast.module.css create mode 100644 packages/client/ui-primitives/src/Toast.tsx create mode 100644 packages/client/ui-primitives/tests/toast.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml new file mode 100644 index 0000000000..e967409825 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.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-11-web-attachment-display-alignment.md +2026-08-11-web-attachment-display-alignment.md: 84d51aada0a463145115f0cebfaf73e9b2bd9e9b +2026-08-11-web-attachment-display-alignment.zh.md: 2676fcea333b54017c37cb4ee85f59ad7c76cfac diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md new file mode 100644 index 0000000000..84d51aada0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md @@ -0,0 +1,31 @@ +# Agent Note: Web attachment display aligns with DeepSeek Chat via attachment atoms + +Status: implemented + +English | [中文](2026-08-11-web-attachment-display-alignment.zh.md) + +## Problem + +The web composer's image surfaces missed basic usability (user feedback, issue #2248). The remove control hung outside each 72px thumbnail at `top/right: -6px`, so the rail's `overflow-x` box clipped it and clicks aimed at it often missed; previews opened only on double-click, an affordance nothing advertised except a tooltip; a rail wider than the composer produced a raw horizontal scrollbar inside the capsule; and image-intake rejections plus prompt failures (for example `attachment-error` when the selected model takes no image input) rendered as persistent inline red strips above the card. Every one of these surfaces already has a settled design in DeepSeek Chat that users know: single-click preview, an inside-the-card hover-revealed remove control, hidden-scrollbar arrow paging, and a transient top-center toast. + +All of this UI also lived inside `dsh-client-ui-conversation` — the rail inline in the 700-line `InputBar`, the history image and lightbox in `chat/` and `skeleton/` — with no seam that another surface could reuse and nothing enforcing the pure-props discipline the pieces already had. + +## Decision + +Attachment display lives in a new zero-cordis atoms package, `@deepseek-ai/dsh-client-ui-attachment` (`packages/client/ui-attachment`), patterned on `dsh-client-ui-primitives`: `AttachmentRail` (64px/16px-radius thumbnails, single-click `onOpen`, inside-the-card remove control revealed on hover or focus and permanent under `pointer: coarse`, hidden scrollbar with circular edge arrows recomputed from scroll geometry, vertical-wheel horizontal pan clamped to 60px/tick, end-reveal on growth), `MessageImage`/`ImageGallery` (single-click preview), and `ImageLightbox`. Strings arrive as label props; `ui-conversation` bridges its `conversation` dictionary through `src/client/image-labels.ts` and keeps the machine wiring (draft ids, preview state, intake callbacks). The cross-package import is sanctioned exactly because the package is an atoms library, not a client plugin: plugin-to-plugin component imports stay forbidden, and the composer's rail is composer-owned rendering, not a slot. + +The transient banner is a `ui-primitives` `Toast` atom (top-center, `role="alert"`, three-second hold then one-second fade, `onDone` unmount, keyed per show so identical repeated messages re-announce). `InputBar` routes both intake rejections (`addImages`'s returned reason) and `promptError` through it, replacing the inline strips; the machine-notice strip is untouched. DeepSeek Chat's source (a local reference copy) provided the target behaviors: its `ImageThumbnailInInput` (64px cards, opacity-transition delete), `ScrollArrows` (sentinel-driven paging), and `useToast` usage. + +## Alternatives considered + +**Keep the components inside `ui-conversation` and only restyle.** Rejected by the user: the attachment surface is expected to grow (file cards, upload progress), and the repo's plugin discipline forbids other plugins importing `ui-conversation` internals, so growth inside the plugin builds an unreusable pile. The atoms package gives the same components a sanctioned import path. + +**A `ui-attachment` client plugin registering slots.** Rejected: the rail renders inside the composer the machine owns and the gallery inside chat nodes; neither is a composition hole another plugin should fill, and a plugin would force slot indirection for what are pure presentational components. + +**Toast inside `ui-conversation`.** Rejected: nothing about a transient banner is conversation-specific, and `ui-primitives` is the established home for zero-cordis atoms other surfaces may reuse. + +**Keep inline error strips and only add the toast for image intake.** Rejected: `promptError` (the `attachment-error` screenshot in the issue) is the surface users actually complained about, and two error presentations in one composer would leave the strip as the odd survivor. + +## Consequences + +The composer and history image surfaces now match DeepSeek Chat's interaction model, and the label-prop seam means the atoms render under any locale without reaching for one. The cost is a real package boundary: `ui-attachment` carries the standard scaffolding (invariant companion, bilingual README, tsconfig face, per-file 100% coverage) and item strings must be resolved by every future consumer rather than inherited. Error banners are now transient — a user who looks away for four seconds misses the message, the trade DeepSeek Chat itself makes. Non-image attachments remain unsupported; the rail's card model is ready for them but the composer's intake is image-only (tracked in the package README's limitations). diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md new file mode 100644 index 0000000000..2676fcea33 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Web 附件展示经附件原子组件对齐 DeepSeek Chat + +Status: implemented + +[English](2026-08-11-web-attachment-display-alignment.md) | 中文 + +## 问题 + +Web 输入框的图片界面缺乏基本可用性(用户反馈,issue #2248)。删除按钮以 `top/right: -6px` 挂在 72px 缩略图外侧,被附件栏的 `overflow-x` 盒子裁切,点击经常落空;预览只能双击打开,除了 tooltip 没有任何提示这个操作;附件栏超出输入框宽度时在胶囊内部直接出现原生横向滚动条;图片接收被拒和发送失败(例如所选模型不支持图片输入时的 `attachment-error`)以常驻的内联红条显示在卡片上方。这些界面在 DeepSeek Chat 里都有用户熟悉的既定设计:单击预览、卡片内部悬停显示的删除按钮、隐藏滚动条的箭头翻页、顶部居中的短时 toast。 + +这些 UI 还全部住在 `dsh-client-ui-conversation` 里——附件栏内联在 700 行的 `InputBar` 中,历史图片和灯箱分散在 `chat/` 与 `skeleton/`——没有其他界面可复用的接缝,纯 props 的纪律也无从约束。 + +## 决定 + +附件展示落位到新的零 cordis 原子组件包 `@deepseek-ai/dsh-client-ui-attachment`(`packages/client/ui-attachment`),模式照 `dsh-client-ui-primitives`:`AttachmentRail`(64px、16px 圆角缩略图,单击 `onOpen`,卡片内部的删除按钮悬停或聚焦显示、`pointer: coarse` 下常显,隐藏滚动条配两端圆形箭头并依滚动几何重算,纵向滚轮转横向平移且单次钳制 60px,新增条目滚到栏尾),`MessageImage`/`ImageGallery`(单击预览),以及 `ImageLightbox`。文案经 label props 传入;`ui-conversation` 通过 `src/client/image-labels.ts` 桥接 `conversation` 词典,并保留状态机接线(草稿 id、预览状态、接收回调)。跨包 import 之所以是被允许的路径,正因为它是原子组件库而非 client 插件:插件之间仍禁止互相 import 组件,且附件栏是输入框自有的渲染,不是插槽。 + +短时横幅是 `ui-primitives` 的 `Toast` 原子(顶部居中,`role="alert"`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。 + +## 备选方案 + +**组件留在 `ui-conversation` 里只改样式。** 被用户否决:附件面预期还会长(文件卡片、上传进度),而仓库的插件纪律禁止其他插件 import `ui-conversation` 内部实现,在插件里生长只会堆出无法复用的一坨。原子组件包给了同样的组件一条被允许的 import 路径。 + +**做成注册插槽的 `ui-attachment` client 插件。** 否决:附件栏渲染在状态机持有的输入框里,画廊渲染在聊天节点里,二者都不是该由其他插件填充的组合孔位,插件形态会为纯展示组件强加插槽间接层。 + +**Toast 放在 `ui-conversation`。** 否决:短时横幅没有任何会话特有的东西,`ui-primitives` 是零 cordis 原子组件的既定归属,其他界面也可能复用。 + +**保留内联红条,只给图片接收加 toast。** 否决:`promptError`(issue 截图里的 `attachment-error`)恰是用户实际抱怨的界面,一个输入框里存在两种错误呈现会让红条成为孤例。 + +## 结果 + +输入框与历史图片界面的交互模型现已与 DeepSeek Chat 一致,label props 接缝让原子组件在任何语言环境下渲染而无需触达 locale。代价是一个真实的包边界:`ui-attachment` 背上标准脚手架(invariant 伴生、双语 README、tsconfig face、逐文件 100% 覆盖率),且每个未来消费者都要自行解析条目文案而非继承。错误横幅变为短时——用户移开视线四秒就会错过消息,这正是 DeepSeek Chat 自己做的取舍。非图片附件仍不支持;附件栏的卡片模型已就绪,但输入框的接收仍只认图片(记录于包 README 的限制一节)。 diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index df286ec1c3..9f22cb55f7 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -4,7 +4,7 @@ // Opens the fixture history session whose turn 72 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized -// sessions.attachment route, the double-click ImageLightbox, and the composer +// sessions.attachment route, the single-click ImageLightbox, and the composer // intake chain (paste → ordered thumbnail rail → image-only send enablement → remove). import { fireEvent, screen, waitFor, within } from '@testing-library/react' import { expect, it } from 'vitest' @@ -66,10 +66,10 @@ it('renders the history image pair through the authorized attachment route and o `) const userImage = document.querySelector('[data-align="end"] img')! - // Double-click opens the original-size lightbox; Escape/close dismisses it. + // A single click opens the original-size lightbox; Escape/close dismisses it. const frame = userImage.closest('button') if (frame === null) throw new Error('image frame button missing') - fireEvent.doubleClick(frame) + fireEvent.click(frame) const lightbox = await screen.findByRole('dialog') expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob') fireEvent.click(within(lightbox).getByRole('button', { name: /Close/ })) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 66076c1171..5d77e13e03 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -143,6 +143,7 @@ export default defineConfig({ { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') }, + { find: /^@deepseek-ai\/dsh-client-ui-attachment$/, replacement: src('../../packages/client/ui-attachment/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') }, ], diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 098c91c804..099444a5cc 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: 911255077833354351b08bd2800f2116510ca3c0 -config-catalog.zh.md: d3141ab389cb1b8f60b88d504e2598ab1938decc +config-catalog.md: 59e426a484d7ed1db67bc3d48490b069e04bc1e3 +config-catalog.zh.md: e64d9f0bdb9bcedc2084c3552c6b5e71c561c75c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9112550778..59e426a484 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2817,6 +2817,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-schema-form` ([`packages/client/schema-form/src/index.ts`](../packages/client/schema-form/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime` ([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives` ([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d3141ab389..e64d9f0bdb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2817,6 +2817,7 @@ export interface Config { - `@deepseek-ai/dsh-brand`([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-client-schema-form`([`packages/client/schema-form/src/index.ts`](../packages/client/schema-form/src/index.ts)) - `@deepseek-ai/dsh-client-test-runtime`([`packages/client/test-runtime/src/index.ts`](../packages/client/test-runtime/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts)) - `@deepseek-ai/dsh-client-ui-primitives`([`packages/client/ui-primitives/src/index.ts`](../packages/client/ui-primitives/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slots`([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index c1f21425d6..e8c6113e2b 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: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67 -README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c +README.md: 40005f982e4003a6ea211b958002940ef73d77ef +README.zh.md: 4d51d7a425fe531a558d8f61f048412572ed153d diff --git a/packages/client/README.md b/packages/client/README.md index bbc32fb394..40005f982e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -18,6 +18,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. | | [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. | | [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. | +| [`ui-attachment/`](ui-attachment/README.md) | Provides attachment display atoms: draft-image rail, message gallery, and lightbox. | | [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. | | [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. | | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5bfbd1ce6b..4d51d7a425 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -18,6 +18,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-slots/`](ui-slots/README.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 | | [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 | | [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 | +| [`ui-attachment/`](ui-attachment/README.md) | 提供附件展示原子组件:草稿图片栏、消息画廊与灯箱。 | | [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 | | [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 | | [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 | diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml new file mode 100644 index 0000000000..4a66c5ebe6 --- /dev/null +++ b/packages/client/ui-attachment/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-attachment/README.md +README.md: a0a410532a631f243c3dfef7debfc6ce0bb0699e +README.zh.md: 4d8ae638a30dc37d89531f15cf3c541d8e09f8f6 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md new file mode 100644 index 0000000000..a0a410532a --- /dev/null +++ b/packages/client/ui-attachment/README.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-client-ui-attachment + +English | [中文](README.zh.md) + +Pure React attachment atoms (zero cordis): the composer draft-image rail (`AttachmentRail`), the chat-history image gallery (`MessageImage`/`ImageGallery`), and the original-image lightbox (`ImageLightbox`). Every string arrives through label props resolved by the owning plugin's own locale namespace, and nothing here reads application state; `@deepseek-ai/dsh-client-ui-conversation` is the current consumer, bridging its `conversation` dictionary through its `image-labels` module. + +## Attachment rail + +`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling, and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and window resizes. A vertical wheel pans the rail horizontally with per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist. + +## Message images and the lightbox + +`MessageImage` renders one durable history image bounded to 240px on its longer edge, loading a session-authorized URL through the owner's `ImageLoader`; a failed load renders an explicit retry control, and a settled load answers a single click by opening `ImageLightbox` (clicks during loading are ignored). `ImageGallery` wraps a message's images in one aligned flex group (`end` for user messages, `start` for assistant messages) and renders nothing for an empty list. `ImageLightbox` is a document-level modal preview that closes on Escape, a backdrop press, or its close control, and restores focus to its opener on unmount. + +## Model Experience + +None, as the package renders pure React atoms in the browser; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload-progress states wait until the composer accepts non-image attachments. +- **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only. diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md new file mode 100644 index 0000000000..4d8ae638a3 --- /dev/null +++ b/packages/client/ui-attachment/README.zh.md @@ -0,0 +1,26 @@ +# @deepseek-ai/dsh-client-ui-attachment + +[English](README.md) | 中文 + +纯 React 附件原子组件(零 cordis):输入框草稿图片栏(`AttachmentRail`)、聊天历史图片画廊(`MessageImage`/`ImageGallery`)与原图灯箱(`ImageLightbox`)。所有文案都由持有方插件在自己的语言命名空间中解析后经 label props 传入,此包不读取任何应用状态;当前消费者是 `@deepseek-ai/dsh-client-ui-conversation`,经其 `image-labels` 模块桥接 `conversation` 词典。 + +## 附件栏 + +`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动,箭头的显隐在滚动、条目数量变化和窗口尺寸变化时依据滚动几何重算。纵向滚轮转为横向平移,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。 + +## 消息图片与灯箱 + +`MessageImage` 渲染一张持久化历史图片,长边收敛到 240px,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。 + +## Model Experience + +None, as the package renders pure React atoms in the browser; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。 +- **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json new file mode 100644 index 0000000000..3174f4c8b9 --- /dev/null +++ b/packages/client/ui-attachment/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-attachment", + "description": "Pure React attachment atoms for the dsh web UI: draft-image rail, message image gallery, and original-image lightbox (zero cordis)", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-attachment" + }, + "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" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "clsx": "^2.0.0", + "react": "^18.2.0" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" + } +} diff --git a/packages/client/ui-attachment/src/AttachmentRail.module.css b/packages/client/ui-attachment/src/AttachmentRail.module.css new file mode 100644 index 0000000000..5e53374af2 --- /dev/null +++ b/packages/client/ui-attachment/src/AttachmentRail.module.css @@ -0,0 +1,112 @@ +/* Thumbnail geometry mirrors DeepSeek Chat's composer rail: 64px cards with a + 16px radius, remove control fully inside the card, arrows overlaid at the + edges instead of a scrollbar. */ + +.root { + position: relative; + min-width: 0; +} + +.rail { + display: flex; + gap: 10px; + overflow-x: auto; + overflow-y: hidden; + /* Edge arrows page the overflow; the scrollbar stays hidden (both engines). */ + scrollbar-width: none; + /* The rail scrolls on the composer's elevated input surface: bind the l2 + pair (ui-theme styles/scrollbar.css rebinding contract) so anything that + does draw a thumb here matches the surface. */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.rail::-webkit-scrollbar { + display: none; +} + +.item { + position: relative; + flex: 0 0 64px; + width: 64px; + height: 64px; +} + +.thumbnail { + width: 64px; + height: 64px; + padding: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 16px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.thumbnail img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; + display: grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--dsw-alias-button-contrast-fill); + color: var(--dsw-alias-label-primary-inverted); + cursor: pointer; + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +.item:hover .remove, +.remove:focus-visible { + opacity: 1; +} + +/* Touch surfaces have no hover to reveal the control. */ +@media (pointer: coarse) { + .remove { + opacity: 1; + } +} + +.arrow { + position: absolute; + top: 50%; + z-index: 2; + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 999px; + background: var(--dsw-specific-input-major); + color: var(--dsw-alias-label-secondary); + box-shadow: var(--dsw-shadow-lv2); + cursor: pointer; + transform: translateY(-50%); +} + +.arrow:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); +} + +.arrowLeft { + left: 4px; +} + +.arrowRight { + right: 4px; +} diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx new file mode 100644 index 0000000000..db83184efc --- /dev/null +++ b/packages/client/ui-attachment/src/AttachmentRail.tsx @@ -0,0 +1,152 @@ +/** Draft-attachment thumbnail rail: scrollbar-less horizontal overflow paged + * by edge arrows, hover-revealed per-item remove, single-click open. */ + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { WheelEvent } from 'react' +import clsx from 'clsx' +import { + IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14, +} from '@deepseek-ai/dsh-client-ui-primitives' +import css from './AttachmentRail.module.css' + +/** One rail thumbnail; strings arrive resolved (zero-cordis atom). */ +export interface AttachmentRailItem { + /** Stable identity for the React key. */ + id: string + /** Object or data URL rendered as the thumbnail. */ + previewUrl: string + /** Image alt text (display name with the owner's fallback applied). */ + alt: string + /** Accessible label of the item's remove control. */ + removeLabel: string +} + +/** Rail-level strings the owner resolves from its own locale namespace. */ +export interface AttachmentRailLabels { + /** Accessible name of the rail group. */ + group: string + /** Thumbnail tooltip inviting the original-image preview. */ + open: string + /** Accessible label of the left paging arrow. */ + scrollLeft: string + /** Accessible label of the right paging arrow. */ + scrollRight: string +} + +/** + * Horizontal thumbnail rail over the caller's draft attachments. + * + * The rail scrolls with its scrollbar hidden; overflow is announced by edge + * arrows recomputed from scroll geometry on scroll, item-count changes, and + * window resizes. A vertical wheel pans horizontally, a newly added item is + * revealed at the rail's end, and each thumbnail opens on a single click while + * its remove control sits inside the card and reveals on hover or focus. + * The owner decides mounting; it renders the rail only while items exist. + * + * @param props.items - resolved thumbnails in draft order. + * @param props.labels - rail-level strings (group name, open tooltip, arrows). + * @param props.onOpen - single-click open of one item's original image. + * @param props.onRemove - remove one item from the draft. + * @returns the rail group with its paging arrows. + */ +export function AttachmentRail({ items, labels, onOpen, onRemove }: { + items: readonly T[] + labels: AttachmentRailLabels + onOpen: (item: T) => void + onRemove: (item: T) => void +}) { + const railRef = useRef(null) + const countRef = useRef(0) + const [edges, setEdges] = useState({ left: false, right: false }) + const updateEdges = useCallback(() => { + const el = railRef.current + /* v8 ignore next -- defensive: every caller runs while the rail element is mounted. */ + if (el === null) return + // 1px slack: engines report fractional scroll positions at the edges. + const left = el.scrollLeft > 1 + const right = el.scrollLeft < el.scrollWidth - el.clientWidth - 1 + setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right }) + }, []) + useLayoutEffect(() => { + const grew = items.length > countRef.current + countRef.current = items.length + const el = railRef.current + // A newly added attachment lands at the rail's end: reveal it. + if (grew && el !== null) el.scrollLeft = el.scrollWidth - el.clientWidth + updateEdges() + }, [items.length, updateEdges]) + useEffect(() => { + window.addEventListener('resize', updateEdges) + return () => { window.removeEventListener('resize', updateEdges) } + }, [updateEdges]) + const page = (direction: -1 | 1): void => { + const el = railRef.current + /* v8 ignore next -- defensive: the arrows render only while the rail is mounted, so a click cannot find a null ref. */ + if (el === null) return + // One viewport minus a card keeps the last visible thumbnail as context; + // the floor keeps narrow rails paging a useful distance. + el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: 'smooth' }) + } + // A vertical wheel pans the rail horizontally (trackpads pan natively via + // deltaX); per-tick travel is clamped so a fast notch wheel stays followable. + const onWheel = (event: WheelEvent): void => { + if (event.deltaX !== 0 || event.deltaY === 0) return + event.currentTarget.scrollBy({ + left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY), 60), + behavior: 'auto', + }) + } + return ( +
+ {edges.left && ( + + )} +
+ {items.map(item => ( +
+ + +
+ ))} +
+ {edges.right && ( + + )} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css b/packages/client/ui-attachment/src/ImageLightbox.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css rename to packages/client/ui-attachment/src/ImageLightbox.module.css diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx b/packages/client/ui-attachment/src/ImageLightbox.tsx similarity index 54% rename from packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx rename to packages/client/ui-attachment/src/ImageLightbox.tsx index 43cbc441a5..dcf01bbc41 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx +++ b/packages/client/ui-attachment/src/ImageLightbox.tsx @@ -1,13 +1,30 @@ import { useEffect, useRef } from 'react' -import type { ChatViewSlotProps } from '../contract/slots.ts' import css from './ImageLightbox.module.css' -/** Document-level original-image preview opened by an explicit double-click. */ -export function ImageLightbox({ src, alt, onClose, t }: { +/** Lightbox strings the owner resolves from its own locale namespace. */ +export interface ImageLightboxLabels { + /** Accessible name of the preview dialog. */ + dialog: string + /** Accessible label of the close control. */ + close: string +} + +/** + * Document-level original-image preview opened by clicking a thumbnail. + * Closes on Escape, backdrop press, or the close control, and restores focus + * to the opener on unmount. + * + * @param props.src - the original image URL. + * @param props.alt - the image's alt text. + * @param props.labels - dialog and close-control strings. + * @param props.onClose - dismiss callback owned by the opener. + * @returns the modal preview dialog. + */ +export function ImageLightbox({ src, alt, labels, onClose }: { src: string alt: string + labels: ImageLightboxLabels onClose: () => void - t: ChatViewSlotProps['t'] }) { const closeRef = useRef(null) const restoreRef = useRef(null) @@ -30,11 +47,11 @@ export function ImageLightbox({ src, alt, onClose, t }: { className={css.backdrop} role="dialog" aria-modal="true" - aria-label={t('image.preview')} + aria-label={labels.dialog} onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }} > {alt} - + ) } diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.module.css b/packages/client/ui-attachment/src/MessageImage.module.css similarity index 97% rename from packages/client/ui-conversation/src/client/chat/MessageImage.module.css rename to packages/client/ui-attachment/src/MessageImage.module.css index e05a6fc625..17ac423640 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageImage.module.css +++ b/packages/client/ui-attachment/src/MessageImage.module.css @@ -24,7 +24,7 @@ padding: 0; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; + border-radius: 16px; background: var(--dsw-alias-interactive-bg-hover); cursor: zoom-in; } diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx similarity index 52% rename from packages/client/ui-conversation/src/client/chat/MessageImage.tsx rename to packages/client/ui-attachment/src/MessageImage.tsx index 3f22ff72b8..943d1fd158 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,17 +1,41 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { ChatViewSlotProps } from '../contract/slots.ts' -import { ImageLightbox } from '../skeleton/ImageLightbox.tsx' +import { ImageLightbox } from './ImageLightbox.tsx' +import type { ImageLightboxLabels } from './ImageLightbox.tsx' import css from './MessageImage.module.css' /** Loads a session-authorized durable image URL. */ export type ImageLoader = (attachment: ImageAttachmentRef) => Promise -/** Compact history renderer with retryable loading and double-click original preview. */ -export function MessageImage({ attachment, load, t }: { +/** Message-image strings the owner resolves from its own locale namespace. */ +export interface MessageImageLabels { + /** Fallback display name for an unnamed image. */ + image: string + /** Thumbnail tooltip inviting the original-image preview. */ + open: string + /** Accessible thumbnail label; receives the image's display name. */ + openNamed: (label: string) => string + /** Loading placeholder shown until bytes resolve. */ + loading: string + /** Retry-control label shown when the load fails. */ + loadFailed: string + /** Lightbox strings forwarded to the opened preview. */ + lightbox: ImageLightboxLabels +} + +/** + * Compact history renderer with retryable loading and click-to-open original + * preview. + * + * @param props.attachment - the durable image reference to load and bound. + * @param props.load - session-authorized URL loader. + * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). + * @returns the bounded thumbnail button, or the retry control on failure. + */ +export function MessageImage({ attachment, load, labels }: { attachment: ImageAttachmentRef load: ImageLoader - t: ChatViewSlotProps['t'] + labels: MessageImageLabels }) { const [src, setSrc] = useState(null) const [error, setError] = useState(false) @@ -35,37 +59,37 @@ export function MessageImage({ attachment, load, t }: { return () => { live = false } }, [attachment, load]) - const label = attachment.name ?? t('image.label') - if (error) return + const label = attachment.name ?? labels.image + if (error) return return ( <> - {open && src !== null && } + {open && src !== null && } ) } /** Wrapping image group shared by user and assistant history. */ -export function ImageGallery({ images, load, align, t }: { +export function ImageGallery({ images, load, align, labels }: { images: readonly { attachment: ImageAttachmentRef }[] load: ImageLoader align: 'start' | 'end' - t: ChatViewSlotProps['t'] + labels: MessageImageLabels }) { if (images.length === 0) return null return (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-attachment/src/css-modules.d.ts b/packages/client/ui-attachment/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-attachment/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-attachment/src/index.ts b/packages/client/ui-attachment/src/index.ts new file mode 100644 index 0000000000..8757915fee --- /dev/null +++ b/packages/client/ui-attachment/src/index.ts @@ -0,0 +1,14 @@ +/** + * Pure React attachment atoms (zero cordis): the composer draft-image rail, + * the chat-history image gallery, and the original-image lightbox. Owners + * resolve every string through their own locale namespace and pass it down; + * nothing here reads application state. + * @module @deepseek-ai/dsh-client-ui-attachment + */ + +export { AttachmentRail } from './AttachmentRail.tsx' +export type { AttachmentRailItem, AttachmentRailLabels } from './AttachmentRail.tsx' +export { ImageLightbox } from './ImageLightbox.tsx' +export type { ImageLightboxLabels } from './ImageLightbox.tsx' +export { ImageGallery, MessageImage } from './MessageImage.tsx' +export type { ImageLoader, MessageImageLabels } from './MessageImage.tsx' diff --git a/packages/client/ui-attachment/src/invariant.ts b/packages/client/ui-attachment/src/invariant.ts new file mode 100644 index 0000000000..47d18f97b8 --- /dev/null +++ b/packages/client/ui-attachment/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-attachment`. + * @module @deepseek-ai/dsh-client-ui-attachment/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-attachment' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-attachment-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: pure props-in React atoms with no Cordis API — + * no events, no services, no mutable cross-plugin state; rendering contracts + * are asserted directly by this package's component specs. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-attachment/tests/attachment-rail.spec.tsx b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx new file mode 100644 index 0000000000..de468bd6ce --- /dev/null +++ b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +// AttachmentRail behavior in the jsdom lane: item rendering and callbacks, +// arrow paging over stubbed scroll geometry (jsdom lays nothing out), the +// vertical-wheel pan, and the new-item end reveal. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { AttachmentRail } from '../src/AttachmentRail.tsx' +import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx' + +afterEach(cleanup) + +const labels: AttachmentRailLabels = { + group: '待发送图片', + open: '查看原图', + scrollLeft: '向左滚动图片', + scrollRight: '向右滚动图片', +} + +function item(id: string): AttachmentRailItem { + return { id, previewUrl: `blob:${id}`, alt: `${id}.png`, removeLabel: `移除图片 ${id}.png` } +} + +/** Stub the rail's scroll geometry (jsdom reports 0 for every metric). */ +function stubGeometry(rail: HTMLElement, { scrollWidth, clientWidth }: { scrollWidth: number; clientWidth: number }) { + Object.defineProperty(rail, 'scrollWidth', { value: scrollWidth, configurable: true }) + Object.defineProperty(rail, 'clientWidth', { value: clientWidth, configurable: true }) + let scrollLeft = 0 + Object.defineProperty(rail, 'scrollLeft', { + configurable: true, + get: () => scrollLeft, + set: (value: number) => { scrollLeft = value }, + }) + const scrollBy = vi.fn((options: { left: number }) => { + scrollLeft = Math.max(0, Math.min(scrollWidth - clientWidth, scrollLeft + options.left)) + }) + rail.scrollBy = scrollBy as unknown as typeof rail.scrollBy + return { scrollBy, setScrollLeft: (value: number) => { scrollLeft = value } } +} + +describe('AttachmentRail', () => { + it('renders thumbnails in order and routes open and remove clicks', () => { + const onOpen = vi.fn() + const onRemove = vi.fn() + const items = [item('a'), item('b')] + const view = render() + const rail = view.getByRole('group', { name: '待发送图片' }) + expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))).toEqual(['a.png', 'b.png']) + fireEvent.click(view.getAllByTitle('查看原图')[0]!) + expect(onOpen).toHaveBeenCalledWith(items[0]) + fireEvent.click(view.getByRole('button', { name: '移除图片 b.png' })) + expect(onRemove).toHaveBeenCalledWith(items[1]) + }) + + it('shows edge arrows from scroll geometry and pages a viewport at a time', () => { + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + // No arrows until geometry is observed (mount saw jsdom's zero metrics). + expect(view.queryByLabelText('向右滚动图片')).toBeNull() + fireEvent.scroll(rail) + // Same-edges scroll takes the memoized-state path. + fireEvent.scroll(rail) + expect(view.queryByLabelText('向左滚动图片')).toBeNull() + const right = view.getByLabelText('向右滚动图片') + // clientWidth 200 - 64 < the 200 floor: pages by the floor. + fireEvent.click(right) + expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior: 'smooth' }) + fireEvent.scroll(rail) + // Scrolled to the far edge: only the left arrow remains. + expect(view.queryByLabelText('向右滚动图片')).toBeNull() + fireEvent.click(view.getByLabelText('向左滚动图片')) + expect(scrollBy).toHaveBeenCalledWith({ left: -200, behavior: 'smooth' }) + fireEvent.scroll(rail) + expect(view.queryByLabelText('向左滚动图片')).toBeNull() + expect(view.getByLabelText('向右滚动图片')).toBeTruthy() + }) + + it('shows both arrows mid-scroll and recomputes on window resize', () => { + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + setScrollLeft(100) + fireEvent(window, new Event('resize')) + expect(view.getByLabelText('向左滚动图片')).toBeTruthy() + expect(view.getByLabelText('向右滚动图片')).toBeTruthy() + }) + + it('pans horizontally on a vertical wheel with clamped travel', () => { + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + fireEvent.wheel(rail, { deltaY: 30 }) + expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' }) + fireEvent.wheel(rail, { deltaY: 500 }) + expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' }) + fireEvent.wheel(rail, { deltaY: -500 }) + expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' }) + // A trackpad pan (deltaX) and a zero-delta wheel keep native behavior. + fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 }) + fireEvent.wheel(rail, { deltaY: 0 }) + expect(scrollBy).toHaveBeenCalledTimes(3) + }) + + it('reveals the rail end when an item is added, not when one is removed', () => { + const first = [item('a'), item('b')] + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + view.rerender( + , + ) + expect(rail.scrollLeft).toBe(200) + view.rerender( + , + ) + // Removal keeps the position; only growth jumps to the end. + expect(rail.scrollLeft).toBe(200) + }) +}) diff --git a/packages/client/ui-attachment/tests/image-lightbox.spec.tsx b/packages/client/ui-attachment/tests/image-lightbox.spec.tsx new file mode 100644 index 0000000000..6152fc5ec2 --- /dev/null +++ b/packages/client/ui-attachment/tests/image-lightbox.spec.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { ImageLightbox } from '../src/ImageLightbox.tsx' + +afterEach(cleanup) + +const labels = { dialog: '原图预览', close: '关闭原图预览' } + +describe('ImageLightbox', () => { + it('focuses its close control, closes by button and Escape, and restores focus', () => { + const opener = document.createElement('button') + document.body.appendChild(opener) + opener.focus() + const onClose = vi.fn() + const view = render() + const close = view.getByRole('button', { name: '关闭原图预览' }) + expect(document.activeElement).toBe(close) + fireEvent.keyDown(window, { key: 'a' }) + expect(onClose).not.toHaveBeenCalled() + fireEvent.keyDown(window, { key: 'Escape' }) + fireEvent.click(close) + expect(onClose).toHaveBeenCalledTimes(2) + view.unmount() + expect(document.activeElement).toBe(opener) + opener.remove() + }) + + it('tolerates a focus owner it cannot restore (no active element at mount)', () => { + // jsdom always reports body as the fallback active element; stub the + // element-less state a detached focus can leave. + Object.defineProperty(document, 'activeElement', { configurable: true, get: () => null }) + try { + const view = render() + view.unmount() + } finally { + delete (document as { activeElement?: unknown }).activeElement + } + }) + + it('closes on a backdrop press but not on a press over the image', () => { + const onClose = vi.fn() + const view = render() + fireEvent.mouseDown(view.getByRole('img')) + expect(onClose).not.toHaveBeenCalled() + fireEvent.mouseDown(view.getByRole('dialog', { name: '原图预览' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/client/ui-attachment/tests/invariant.spec.ts b/packages/client/ui-attachment/tests/invariant.spec.ts new file mode 100644 index 0000000000..4e76b3776f --- /dev/null +++ b/packages/client/ui-attachment/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import * as AttachmentInvariant from '@deepseek-ai/dsh-client-ui-attachment/invariant' +import InvariantService from '@deepseek-ai/dsh-invariants' + +describe('invariant companion', () => { + it('registers under the package name with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(AttachmentInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/client/ui-attachment/tests/message-image.spec.tsx b/packages/client/ui-attachment/tests/message-image.spec.tsx new file mode 100644 index 0000000000..6dbf4cd746 --- /dev/null +++ b/packages/client/ui-attachment/tests/message-image.spec.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { ImageGallery, MessageImage } from '../src/MessageImage.tsx' +import type { MessageImageLabels } from '../src/MessageImage.tsx' + +afterEach(cleanup) + +const labels: MessageImageLabels = { + image: '图片', + open: '查看原图', + openNamed: label => `${label},点击查看原图`, + loading: '图片加载中…', + loadFailed: '图片加载失败,点击重试', + lightbox: { dialog: '原图预览', close: '关闭原图预览' }, +} + +const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 640, + height: 320, + name: 'history.png', +} + +describe('MessageImage', () => { + it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { + const load = vi.fn().mockResolvedValue('blob:history') + const view = render() + const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) + expect(frame.getAttribute('style')).toContain('width: 240px') + expect(frame.getAttribute('style')).toContain('height: 120px') + expect(frame.getAttribute('title')).toBe('查看原图') + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledWith(attachment) + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) + + it('ignores a click while the thumbnail is still loading', () => { + const load = vi.fn(() => new Promise(() => {})) + const view = render() + const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) + expect(view.getByText('图片加载中…')).toBeTruthy() + fireEvent.click(frame) + expect(view.queryByRole('dialog')).toBeNull() + }) + + it('falls back to the image label for an unnamed attachment', async () => { + const { name: _named, ...unnamed } = attachment + const load = vi.fn().mockResolvedValue('blob:unnamed') + const view = render() + await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) + expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() + }) + + it('surfaces a retry control when durable bytes cannot be read, including a failed retry', async () => { + const load = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockRejectedValueOnce(new Error('still offline')) + .mockResolvedValueOnce('blob:retry') + const view = render() + const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) + fireEvent.click(retry) + const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) + fireEvent.click(retryAgain) + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledTimes(3) + }) + + it('ignores a load settling after unmount', async () => { + let resolve: ((url: string) => void) | undefined + const load = vi.fn(() => new Promise((r) => { resolve = r })) + const view = render() + view.unmount() + resolve?.('blob:late') + await Promise.resolve() + let reject: ((error: Error) => void) | undefined + const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) + const second = render() + second.unmount() + reject?.(new Error('late failure')) + await Promise.resolve() + }) +}) + +describe('ImageGallery', () => { + it('renders nothing without images and an aligned wrapping group with them', async () => { + const load = vi.fn().mockResolvedValue('blob:gallery') + const empty = render() + expect(empty.container.firstChild).toBeNull() + const view = render( + , + ) + expect(view.container.querySelector('[data-align="end"]')).not.toBeNull() + await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) }) + }) +}) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json new file mode 100644 index 0000000000..e034eef4e3 --- /dev/null +++ b/packages/client/ui-attachment/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../attachment/attachment" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../ui-primitives" + } + ] +} diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts new file mode 100644 index 0000000000..2ffa80a8d1 --- /dev/null +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -0,0 +1,31 @@ +import { clientOnly } from '../tsdown.client.ts' + +/** + * ui-attachment is browser-only, but its lib bundle IS imported under plain + * Node because the web shell is a lib (dsh-client-web's lib chain reaches + * this package). CSS imports are therefore stubbed to empty modules instead + * of externalized — the hashed class maps only matter in bundler contexts + * (loader module table / vite source paths), which compile src directly and + * never read lib. + */ +export default clientOnly([{ + entry: ['lib/types/index.js', 'lib/types/invariant.js'], + outDir: 'lib', + format: ['esm'], + platform: 'neutral', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + plugins: [{ + name: 'dsh-css-stub', + resolveId(source: string) { + if (!source.endsWith('.css')) return null + return `\0dsh-css-stub:${source}.mjs` + }, + load(id: string) { + if (!id.startsWith('\0dsh-css-stub:')) return null + return 'export default {};' + }, + }], +}]) diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index e80432f79f..bb1be514bb 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -82,6 +83,7 @@ "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 7a8590c2b8..4365f4c453 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -13,8 +13,9 @@ import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { ImageGallery, type ImageLoader } from './MessageImage.tsx' +import { messageImageLabels } from '../image-labels.ts' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -62,7 +63,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ /> ) case 'reasoning': return - case 'image': return + case 'image': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. case 'tool-call': return null default: return ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index f7c451ce8a..d2bfe8868f 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,10 +10,11 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' +import { ImageGallery, type ImageLoader } from '@deepseek-ai/dsh-client-ui-attachment' +import { messageImageLabels } from '../image-labels.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' -import { ImageGallery, type ImageLoader } from './MessageImage.tsx' import css from './MessageItem.module.css' type UserImage = Extract @@ -177,7 +178,7 @@ function UserStyleBubble({ return (
- + {showBubble &&
{projectUserText(text)} {rest.map((block, i) => )} diff --git a/packages/client/ui-conversation/src/client/image-labels.ts b/packages/client/ui-conversation/src/client/image-labels.ts new file mode 100644 index 0000000000..493ddbbbbe --- /dev/null +++ b/packages/client/ui-conversation/src/client/image-labels.ts @@ -0,0 +1,48 @@ +/** Bridges the `conversation` locale namespace to the zero-cordis attachment + * atoms' label props (`@deepseek-ai/dsh-client-ui-attachment` reads no + * application state; owners resolve every string). */ + +import type { + AttachmentRailLabels, ImageLightboxLabels, MessageImageLabels, +} from '@deepseek-ai/dsh-client-ui-attachment' +import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' +import type { ConversationKey } from './locales.ts' + +/** + * Resolve the original-image lightbox strings. + * @param t - the conversation-namespace translate. + * @returns the lightbox dialog and close-control labels. + */ +export function lightboxLabels(t: Translate): ImageLightboxLabels { + return { dialog: t('image.preview'), close: t('image.closePreview') } +} + +/** + * Resolve the chat-history image strings. + * @param t - the conversation-namespace translate. + * @returns the message-image labels including the forwarded lightbox strings. + */ +export function messageImageLabels(t: Translate): MessageImageLabels { + return { + image: t('image.label'), + open: t('image.openOriginal'), + openNamed: label => t('image.openOriginalLabel', { label }), + loading: t('image.loading'), + loadFailed: t('image.loadFailed'), + lightbox: lightboxLabels(t), + } +} + +/** + * Resolve the composer draft-image rail strings. + * @param t - the conversation-namespace translate. + * @returns the rail group, open-tooltip, and paging-arrow labels. + */ +export function attachmentRailLabels(t: Translate): AttachmentRailLabels { + return { + group: t('image.pending'), + open: t('image.openOriginal'), + scrollLeft: t('image.scrollLeft'), + scrollRight: t('image.scrollRight'), + } +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index dcf04264e8..080bb9b67b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -27,9 +27,11 @@ export const zh = { 'input.accessMode': '访问模式,当前:{name}', 'image.dropHint': '松开以添加图片', 'image.pending': '待发送图片', - 'image.openOriginal': '双击查看原图', - 'image.openOriginalLabel': '{label},双击查看原图', + 'image.openOriginal': '查看原图', + 'image.openOriginalLabel': '{label},点击查看原图', 'image.remove': '移除图片 {name}', + 'image.scrollLeft': '向左滚动图片', + 'image.scrollRight': '向右滚动图片', 'image.original': '原图', 'image.label': '图片', 'image.loadFailed': '图片加载失败,点击重试', @@ -184,9 +186,11 @@ export const en = { 'input.accessMode': 'Access mode, current: {name}', 'image.dropHint': 'Drop to add images', 'image.pending': 'Pending images', - 'image.openOriginal': 'Double-click to view original', - 'image.openOriginalLabel': '{label}, double-click to view original', + 'image.openOriginal': 'View original', + 'image.openOriginalLabel': '{label}, click to view original', 'image.remove': 'Remove image {name}', + 'image.scrollLeft': 'Scroll images left', + 'image.scrollRight': 'Scroll images right', 'image.original': 'Original image', 'image.label': 'Image', 'image.loadFailed': 'Image failed to load; click to retry', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index ad1a6ed275..751e253e22 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -35,22 +35,6 @@ padding: 0 var(--dsh-composer-side-clearance); } -.error, -.status { - width: 100%; - max-width: var(--dsh-composer-card-max-width); - margin-bottom: 6px; - padding: 4px 8px; - border-radius: 8px; - font-size: 12px; - line-height: 18px; -} - -.status { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); -} - .notice { width: 100%; max-width: var(--dsh-composer-card-max-width); @@ -68,11 +52,6 @@ color: var(--dsw-alias-state-error-primary); } -.error { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); -} - .card { box-sizing: border-box; position: relative; /* overlay anchor positioning context */ @@ -162,55 +141,13 @@ padding: 10px 12px 0; } +/* Rail seat: the card's top padding (10px) plus this 4px matches DeepSeek + Chat's spacing above the thumbnails; the card's 12px flex gap owns the space + below. The rail itself (arrows, hidden scrollbar, card geometry) is the + ui-attachment atom's. */ .attachments { - display: flex; - gap: 8px; min-width: 0; - padding: 12px 12px 0; - overflow-x: auto; - overflow-y: hidden; -} - -.attachment { - position: relative; - flex: 0 0 72px; - width: 72px; - height: 72px; -} - -.thumbnail { - width: 72px; - height: 72px; - padding: 0; - overflow: hidden; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; - background: var(--dsw-alias-interactive-bg-hover); - cursor: zoom-in; -} - -.thumbnail img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.remove { - position: absolute; - top: -6px; - right: -6px; - display: grid; - place-items: center; - width: 22px; - height: 22px; - padding: 0; - border: 1px solid var(--dsw-specific-input-major); - border-radius: 999px; - background: var(--dsw-alias-label-primary); - color: var(--dsw-specific-input-major); - font-size: 16px; - line-height: 1; - cursor: pointer; + padding: 4px 12px 0; } /* Floating overlay anchor (menu / popupSelect shell): entries position diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 5423a2bac6..555177e6f6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -9,7 +9,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' -import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import { AttachmentRail, ImageLightbox } from '@deepseek-ai/dsh-client-ui-attachment' +import type { AttachmentRailItem } from '@deepseek-ai/dsh-client-ui-attachment' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). import type {} from '@deepseek-ai/dsh-plan-mode/client' @@ -19,18 +23,17 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' +import { attachmentRailLabels, lightboxLabels } from '../image-labels.ts' import { ContextMeter } from './ContextMeter.tsx' -import { ImageLightbox } from './ImageLightbox.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import css from './InputBar.module.css' /** Decoration product of the no-session state (no machine, empty draft). */ const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null } -/** Prompt failure surface (derived from promptError). */ -export interface InputBarError { - op: 'send' | 'stop' - message: string +/** Rail thumbnail carrying its source attachment for the open/remove callbacks. */ +interface ComposerRailItem extends AttachmentRailItem { + attachment: ComposerAttachment } export type InputBarProps = ComposerBarProps @@ -56,12 +59,6 @@ export function InputBar({ const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active)) // Absent (undefined: no frame yet) and cleared (null) both mean no goal. const hasGoal = useProjection('goal', goal => goal != null) - // Prompt failures are ordinary failures (no create/attach transaction - // exists anymore): the strip renders promptError, the draft stays in the - // machine, and the user resubmits. - const error: InputBarError | null = promptError === null - ? null - : { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` } // Session-maybe: the machine faces are absent together while no session is // current; the bar renders the same DOM inert instead of a parallel tree. const live = input !== undefined && keyboard !== undefined && inputActions !== undefined @@ -73,7 +70,22 @@ export function InputBar({ const empty = draft.trim() === '' && attachments.length === 0 const [preview, setPreview] = useState(null) const [dragActive, setDragActive] = useState(false) - const [dropError, setDropError] = useState(null) + // Transient error banner (image-intake rejections and prompt failures): the + // seq keys the Toast so an identical repeated message restarts the + // hold-then-fade cycle instead of silently reusing the faded one. + const [toast, setToast] = useState<{ seq: number; text: string } | null>(null) + const toastSeq = useRef(0) + const showToast = useCallback((text: string) => { + toastSeq.current += 1 + setToast({ seq: toastSeq.current, text }) + }, []) + const dismissToast = useCallback(() => { setToast(null) }, []) + // Prompt failures are ordinary failures (no create/attach transaction exists + // anymore): the toast announces promptError, the draft stays in the machine, + // and the user resubmits. + useEffect(() => { + if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`) + }, [promptError, showToast]) const inputRef = useRef(null) const dragDepthRef = useRef(0) const scrollRef = useRef(null) @@ -369,7 +381,10 @@ export function InputBar({ .filter(item => item.kind === 'file') .map(item => item.getAsFile()) .filter((file): file is File => file !== null) - if (files.length > 0 && addImages !== undefined) setDropError(addImages(files)) + if (files.length > 0 && addImages !== undefined) { + const rejected = addImages(files) + if (rejected !== null) showToast(rejected) + } const text = e.clipboardData.getData('text/plain') if (text === '') { if (files.length > 0) e.preventDefault() @@ -393,7 +408,6 @@ export function InputBar({ event.preventDefault() if (locked || machineBusy || addImages === undefined) return dragDepthRef.current += 1 - setDropError(null) setDragActive(true) } @@ -416,11 +430,24 @@ export function InputBar({ setDragActive(false) if (locked || machineBusy || addImages === undefined) return const dropped = [...event.dataTransfer.files] - if (dropped.length > 0) setDropError(addImages(dropped)) + if (dropped.length > 0) { + const rejected = addImages(dropped) + if (rejected !== null) showToast(rejected) + } } const closePreview = useCallback(() => { setPreview(null) }, []) + // Rail thumbnails with their strings resolved here: the attachment atoms are + // zero-cordis and read no locale. + const railItems = useMemo(() => attachments.map(attachment => ({ + id: attachment.id, + previewUrl: attachment.previewUrl, + alt: attachment.file.name || t('image.pending'), + removeLabel: t('image.remove', { name: attachment.file.name }), + attachment, + })), [attachments, t]) + const onSelect = (e: React.SyntheticEvent): void => { // Any caret/selection gesture ends a live paste attempt (the machine // cannot observe DOM selection). Cheap no-op when none is live. @@ -543,10 +570,13 @@ export function InputBar({ return (
- {error !== null && ( -
- {error.message} -
+ {toast !== null && ( + } + onDone={dismissToast} + /> )} {notice !== null && (
@@ -558,7 +588,6 @@ export function InputBar({ their pointer events), so the WHOLE capsule is the pick target. pointerdown stops here so the Menu's outside-close cannot race the click's reopen (close-then-open flickers the chip's open echo). */} - {dropError !== null &&
{dropError}
}
{t('image.dropHint')}
} {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} - {attachments.length > 0 && ( -
- {attachments.map(attachment => ( -
- - -
- ))} + {railItems.length > 0 && ( +
+ { setPreview(item.attachment) }} + onRemove={(item) => { removeImage?.(item.attachment.id) }} + />
)} {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the @@ -628,10 +642,7 @@ export function InputBar({ ? t('placeholder.steerQueue') : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} - onChange={(event) => { - setDropError(null) - onChange(event) - }} + onChange={onChange} onKeyDown={onKeyDown} onSelect={onSelect} onCopy={(e) => { onCopyOrCut(e, false) }} @@ -712,8 +723,8 @@ export function InputBar({ )} {footer} diff --git a/packages/client/ui-conversation/tests/image-labels.spec.tsx b/packages/client/ui-conversation/tests/image-labels.spec.tsx new file mode 100644 index 0000000000..6aa5810e38 --- /dev/null +++ b/packages/client/ui-conversation/tests/image-labels.spec.tsx @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +// The conversation-side bridge to the ui-attachment atoms: dictionary strings +// flow through image-labels into the gallery, and assistant images keep their +// block position between text blocks. + +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = makeTranslate(zh, commonZh) +const enT = makeTranslate(en, commonZh) + +const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 640, + height: 320, + name: 'history.png', +} + +describe('assistant images through the label bridge', () => { + it('resolves zh dictionary strings and opens the lightbox on a single click', async () => { + const view = render( + Promise.resolve('blob:history')} + />, + ) + const frame = await view.findByRole('button', { name: 'history.png,点击查看原图' }) + expect(frame.getAttribute('title')).toBe('查看原图') + await view.findByAltText('history.png') + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) + + it('resolves the active English dictionary', async () => { + const view = render( + Promise.resolve('blob:history')} + />, + ) + const frame = await view.findByRole('button', { name: 'history.png, click to view original' }) + await view.findByAltText('history.png') + fireEvent.click(frame) + expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() + expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() + }) + + it('keeps assistant images at their original position between text blocks', async () => { + const view = render( + Promise.resolve('blob:middle')} + />, + ) + const image = await view.findByAltText('history.png') + const before = view.getByText('before') + const after = view.getByText('after') + expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f7b0669e3e..084a3a48e5 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -237,15 +237,49 @@ describe('image draft rail', () => { expect(removeImage).toHaveBeenCalledWith('draft-1') }) - it('opens the original image on double-click and closes it with Escape', () => { + it('opens the original image on a single click and closes it with Escape', () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } const { view } = bench({ attachments: [attachment] }) - fireEvent.doubleClick(view.getByTitle('双击查看原图')) + fireEvent.click(view.getByTitle('查看原图')) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() fireEvent.keyDown(window, { key: 'Escape' }) expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() }) + + it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { + vi.useFakeTimers() + try { + const addImages = vi.fn(() => '不支持的图片格式:text/plain') + const { view, textarea } = bench({ addImages }) + const paste = () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'note.txt', { type: 'text/plain' }) }], + getData: () => '', + }, + }) + } + paste() + expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain') + act(() => { vi.advanceTimersByTime(4000) }) + expect(view.queryByRole('alert')).toBeNull() + // The identical rejection re-announces: the toast is keyed per show. + paste() + expect(view.getByRole('alert').textContent).toContain('不支持的图片格式:text/plain') + } finally { + vi.useRealTimers() + } + }) + + it('announces a rejected drop through the same toast', () => { + const addImages = vi.fn(() => '图片读取服务不可用') + const { view } = bench({ addImages }) + const card = view.container.querySelector('[class*="card"]')! + const dataTransfer = { types: ['Files'], files: [new File([Uint8Array.of(1)], 'x.png', { type: 'image/png' })], dropEffect: 'none' } + fireEvent.drop(card, { dataTransfer }) + expect(view.getByRole('alert').textContent).toContain('图片读取服务不可用') + }) }) describe('Enter semantics', () => { @@ -941,10 +975,17 @@ describe('insertText (scoped event body)', () => { }) describe('strips and variants', () => { - it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => { - const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } }) - expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)') - expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull() + it('announces promptError as a fading toast (ordinary failure — no transaction UI, no Retry)', () => { + vi.useFakeTimers() + try { + const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } }) + expect(send.view.container.querySelector('[role="alert"]')?.textContent).toContain('boom (agent-busy)') + expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull() + act(() => { vi.advanceTimersByTime(4000) }) + expect(send.view.container.querySelector('[role="alert"]')).toBeNull() + } finally { + vi.useRealTimers() + } }) it('renders the notice strip from the machine notice store', () => { diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx deleted file mode 100644 index 6da4d42f12..0000000000 --- a/packages/client/ui-conversation/tests/message-image.spec.tsx +++ /dev/null @@ -1,81 +0,0 @@ -// @vitest-environment jsdom - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { MessageImage } from '../src/client/chat/MessageImage.tsx' -import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' -import { en, zh } from '../src/client/locales.ts' - -afterEach(cleanup) - -const t = makeTranslate(zh, commonZh) -const enT = makeTranslate(en, commonZh) - -const attachment = { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png' as const, - bytes: 68, - width: 640, - height: 320, - name: 'history.png', -} - -describe('MessageImage', () => { - it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => { - const load = vi.fn().mockResolvedValue('blob:history') - const view = render() - const frame = view.getByRole('button', { name: 'history.png,双击查看原图' }) - expect(frame.getAttribute('style')).toContain('width: 240px') - expect(frame.getAttribute('style')).toContain('height: 120px') - await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) - expect(load).toHaveBeenCalledWith(attachment) - fireEvent.doubleClick(frame) - expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() - fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) - expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() - }) - - it('surfaces a retry control when durable bytes cannot be read', async () => { - const load = vi.fn() - .mockRejectedValueOnce(new Error('offline')) - .mockResolvedValueOnce('blob:retry') - const view = render() - const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) - fireEvent.click(retry) - await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) - expect(load).toHaveBeenCalledTimes(2) - }) - - it('renders image controls from the active English dictionary', async () => { - const load = vi.fn().mockResolvedValue('blob:history') - const view = render() - const frame = view.getByRole('button', { name: 'history.png, double-click to view original' }) - await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) - fireEvent.doubleClick(frame) - expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() - expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() - }) - - it('keeps assistant images at their original position between text blocks', async () => { - const view = render( - Promise.resolve('blob:middle')} - />, - ) - const image = await view.findByAltText('history.png') - const before = view.getByText('before') - const after = view.getByText('after') - expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) - expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) - }) -}) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 49763e5fb8..e28e9fdb3f 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../ui-slots" }, + { + "path": "../ui-attachment" + }, { "path": "../ui-primitives" }, diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 06dfe45bd8..703a133632 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/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-primitives/README.md -README.md: a9c802c1e43cf06aa0492b39d5052e882a72d9e6 -README.zh.md: 9e67488ccc1d70263bd0b91d167c4c251ee95926 +README.md: 6e2cfed2578a59eec10f6d50b2bb5da3c7019764 +README.zh.md: d651d9fac1c597b33ec95ffe9a0b69b3a69455a1 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a9c802c1e4..6e2cfed257 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -2,12 +2,16 @@ English | [中文](README.zh.md) -Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. +Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the Toast transient banner, the OnboardingSurface first-run takeover (body-portaled mask + opaque stage that holds `#root` inert for exactly its own lifetime), the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. ## Hover cards `HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Historical rationale: [the archived hover-card copy note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md). +## Toast + +`Toast` is the transient top-center banner: it slides in, holds at full opacity for three seconds, fades over one second, then calls `onDone` so the owner can unmount it. It renders `role="alert"` with an optional leading icon slot and takes its copy as a required prop (zero-cordis: the owner localizes). Re-showing the same message requires a remount — owners key the element by a per-show sequence so an identical repeated message restarts the hold-and-fade cycle instead of silently reusing the faded banner. It layers above the ui-attachment image lightbox so a failure reported during a preview stays readable. + ## Markdown rendering `MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 9e67488ccc..d651d9fac1 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -2,12 +2,16 @@ [English](README.md) | 中文 -纯 React 原子组件(零 cordis):StateDot、DisclosureRow、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在且仅在自身生命周期内保持 `#root` 为 `inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` 钩子(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。 +纯 React 原子组件(零 cordis):StateDot、DisclosureRow、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、Toast 短时横幅、OnboardingSurface 首次使用接管层(portal 到 body 的遮罩加不透明展示层,在且仅在自身生命周期内保持 `#root` 为 `inert`)、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` 钩子(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。 ## 悬浮卡片 `HoverCard` 通过指针离开宽限期,使采用 portal 渲染的预览在跨过与锚点之间的间隙时仍可触及。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。历史依据见[已归档的悬浮卡片复制 Agent Note](../../../.agents/notes/archived/feature/2026-07-31-hover-card-click-copy.md)。 +## Toast + +`Toast` 是顶部居中的短时横幅:滑入后满不透明度停留三秒,再用一秒淡出,随后调用 `onDone` 由持有方卸载。它渲染 `role="alert"`,带可选的前置图标插槽,文案是必填 prop(零 cordis,由持有方本地化)。重复展示同一条消息需要重新挂载,持有方用每次展示递增的序号作为 key,让相同文案重新走完停留与淡出,而不是静默复用已淡出的横幅。它的层级高于 ui-attachment 的图片灯箱,预览打开时报出的失败仍然可读。 + ## Markdown 渲染 `MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 diff --git a/packages/client/ui-primitives/src/Toast.module.css b/packages/client/ui-primitives/src/Toast.module.css new file mode 100644 index 0000000000..e4dbdf6bea --- /dev/null +++ b/packages/client/ui-primitives/src/Toast.module.css @@ -0,0 +1,58 @@ +/* Transient top-center banner (DeepSeek Chat toast look): contrast fill, + inverted label, slide-in, then hold-and-fade. The fade delay/duration MUST + agree with HOLD_MS/FADE_MS in Toast.tsx: the component unmounts at their + sum, so a mismatched sheet either cuts the fade or leaves an invisible + banner blocking nothing. */ + +.toast { + position: fixed; + top: 80px; + left: 50%; + /* Above the 1000 the image lightbox backdrop uses: a failure reported while + a preview is open must stay readable. */ + z-index: 1100; + display: flex; + align-items: center; + gap: 10px; + max-width: min(560px, calc(100vw - 48px)); + padding: 12px 16px; + border-radius: 14px; + background: var(--dsw-alias-button-contrast-fill); + color: var(--dsw-alias-label-primary-inverted); + font-size: 14px; + line-height: 22px; + box-shadow: var(--dsw-shadow-lv3); + transform: translateX(-50%); + animation: + dsh-toast-in 160ms ease-out, + dsh-toast-fade 1000ms ease 3000ms forwards; +} + +.icon { + display: grid; + place-items: center; + flex: none; + color: var(--dsw-alias-state-warn-label); +} + +.text { + min-width: 0; +} + +@keyframes dsh-toast-in { + from { + opacity: 0; + transform: translate(-50%, -6px); + } + + to { + opacity: 1; + transform: translate(-50%, 0); + } +} + +@keyframes dsh-toast-fade { + to { + opacity: 0; + } +} diff --git a/packages/client/ui-primitives/src/Toast.tsx b/packages/client/ui-primitives/src/Toast.tsx new file mode 100644 index 0000000000..37352cb460 --- /dev/null +++ b/packages/client/ui-primitives/src/Toast.tsx @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import css from './Toast.module.css' + +/** Full-opacity hold before the fade starts. Must agree with the stylesheet's + * toast-fade delay (Toast.module.css) or the banner unmounts mid-fade. */ +const HOLD_MS = 3000 +/** Fade duration. Must agree with the stylesheet's toast-fade duration. */ +const FADE_MS = 1000 + +/** + * Transient top-center banner: slides in, holds at full opacity, fades out, + * then reports done so the owner can unmount it. Re-showing the same text + * restarts the cycle when the owner remounts the component (key it by a + * per-show sequence). + * + * @param props.text - resolved banner copy; the owner passes localized text. + * @param props.icon - optional leading glyph (e.g. a warning icon). + * @param props.onDone - called once the fade completes; unmount the toast here. + * @returns the floating banner. + */ +export function Toast({ text, icon, onDone }: { + text: string + icon?: ReactNode + onDone: () => void +}) { + useEffect(() => { + const timer = setTimeout(onDone, HOLD_MS + FADE_MS) + return () => { clearTimeout(timer) } + }, [onDone]) + return ( +
+ {icon !== undefined && {icon}} + {text} +
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 18e5b2c7e9..05baf26f8d 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -23,6 +23,7 @@ export { FishLogo } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' +export { Toast } from './Toast.tsx' export { writeClipboard } from './clipboard.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' diff --git a/packages/client/ui-primitives/tests/toast.spec.tsx b/packages/client/ui-primitives/tests/toast.spec.tsx new file mode 100644 index 0000000000..5fdd4d2f48 --- /dev/null +++ b/packages/client/ui-primitives/tests/toast.spec.tsx @@ -0,0 +1,40 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render } from '@testing-library/react' +import { Toast } from '../src/Toast.tsx' + +afterEach(cleanup) + +describe('Toast', () => { + it('announces its text and reports done after the hold-and-fade lifetime', () => { + vi.useFakeTimers() + try { + const onDone = vi.fn() + const view = render(} onDone={onDone} />) + const banner = view.getByRole('alert') + expect(banner.textContent).toContain('最多添加 50 张图片') + expect(view.getByTestId('icon')).toBeTruthy() + vi.advanceTimersByTime(3999) + expect(onDone).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + expect(onDone).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('renders without an icon and cancels its timer on unmount', () => { + vi.useFakeTimers() + try { + const onDone = vi.fn() + const view = render() + expect(view.getByRole('alert').querySelector('[aria-hidden]')).toBeNull() + view.unmount() + vi.advanceTimersByTime(10_000) + expect(onDone).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/client/web/package.json b/packages/client/web/package.json index df5192e070..d23c343b3c 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -29,6 +29,7 @@ "dependencies": { "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", diff --git a/packages/client/web/src/platform.ts b/packages/client/web/src/platform.ts index e7997cf728..dd7248c117 100644 --- a/packages/client/web/src/platform.ts +++ b/packages/client/web/src/platform.ts @@ -10,6 +10,7 @@ export const PLATFORM_MODULES = [ '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-web-react', '@deepseek-ai/dsh-client-ui-primitives', + '@deepseek-ai/dsh-client-ui-attachment', '@deepseek-ai/dsh-client-schema-form', ] as const diff --git a/packages/client/web/src/seed.ts b/packages/client/web/src/seed.ts index 78ed31099e..8299f225c7 100644 --- a/packages/client/web/src/seed.ts +++ b/packages/client/web/src/seed.ts @@ -14,6 +14,7 @@ import * as Cordis from '@deepseek-ai/cordis' import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots' import * as WebReact from '@deepseek-ai/dsh-client-web-react' import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' +import * as UiAttachment from '@deepseek-ai/dsh-client-ui-attachment' import * as SchemaForm from '@deepseek-ai/dsh-client-schema-form' import type { PlatformModule } from './platform.ts' @@ -34,6 +35,7 @@ export function getStaticModules(): Record { '@deepseek-ai/dsh-client-ui-slots': UiSlots, '@deepseek-ai/dsh-client-web-react': WebReact, '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives, + '@deepseek-ai/dsh-client-ui-attachment': UiAttachment, '@deepseek-ai/dsh-client-schema-form': SchemaForm, } satisfies Record } diff --git a/packages/client/web/tsconfig.json b/packages/client/web/tsconfig.json index 9240c34891..db2e3b8ed8 100644 --- a/packages/client/web/tsconfig.json +++ b/packages/client/web/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-slots" }, + { + "path": "../ui-attachment" + }, { "path": "../ui-primitives" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 816737ec3f..bb80f707d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1937,6 +1937,31 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-attachment: + dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + packages/client/ui-command: dependencies: clsx: @@ -2016,6 +2041,9 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-attachment': + specifier: workspace:^ + version: link:../ui-attachment '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../ui-layout @@ -2859,15 +2887,15 @@ importers: specifier: ^9.0.0 version: 9.0.0 devDependencies: - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -2950,6 +2978,9 @@ importers: '@deepseek-ai/dsh-client-schema-form': specifier: workspace:^ version: link:../schema-form + '@deepseek-ai/dsh-client-ui-attachment': + specifier: workspace:^ + version: link:../ui-attachment '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -4495,12 +4526,12 @@ importers: '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace - fflate: - specifier: ^0.8.2 - version: 0.8.3 '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + fflate: + specifier: ^0.8.2 + version: 0.8.3 zod: specifier: ^4.4.3 version: 4.4.3 diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 41f0923ad1..99531843ce 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -61,6 +61,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers nothing model-facing.' }, 'packages/client/test-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers nothing model-facing.' }, 'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, + 'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/web-react': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/schema-form': { kind: 'none', reason: 'Browser-side form-rendering library; registers nothing model-facing.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index a08b438f3a..6bffbed82e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -154,6 +154,7 @@ "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], "@deepseek-ai/dsh-client-ui-slots": ["./packages/client/ui-slots/src"], + "@deepseek-ai/dsh-client-ui-attachment": ["./packages/client/ui-attachment/src"], "@deepseek-ai/dsh-client-ui-primitives": ["./packages/client/ui-primitives/src"], "@deepseek-ai/dsh-client-schema-form": ["./packages/client/schema-form/src"], "@deepseek-ai/dsh-client-schema-form/invariant": ["./packages/client/schema-form/src/invariant.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index fce7d45b7b..6f6c0e3533 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -46,6 +46,7 @@ { "path": "./packages/compact/compact" }, { "path": "./packages/client/ui-slots" }, { "path": "./packages/client/schema-form" }, + { "path": "./packages/client/ui-attachment" }, { "path": "./packages/client/ui-primitives" }, { "path": "./packages/client/web-react" }, { "path": "./packages/client/modules" }, From d06a7e07e560acf36a16453cbcc0d0a25b6d52c5 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Mon, 10 Aug 2026 17:13:01 +0800 Subject: [PATCH 14/56] feat(web): show command inputs in the human transcript --- ...01-goal-command-input-projection.i18n.yaml | 6 + ...026-08-01-goal-command-input-projection.md | 39 +++++ ...-08-01-goal-command-input-projection.zh.md | 39 +++++ .../tests/goal-command-presentation.e2e.ts | 123 ++++++++++++++++ .../goal-command-presentation/ui.expected.md | 21 +++ .../goal-multi-turn-actions/ui.expected.md | 1 + .../queue-actions/layout.expected.md | 1 + apps/web/tsconfig.json | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + .../src/client/sessions/conversation.ts | 5 +- .../runtime/src/client/sessions/session.ts | 10 +- packages/client/runtime/tests/session.spec.ts | 25 +++- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 + packages/client/ui-goal/README.zh.md | 2 + packages/client/ui-goal/package.json | 2 + .../client/GoalCommandInputView.module.css | 25 ++++ .../src/client/GoalCommandInputView.tsx | 30 ++++ .../ui-goal/src/client/goal-command-input.ts | 71 ++++++++++ packages/client/ui-goal/src/client/index.ts | 13 +- packages/client/ui-goal/src/client/locales.ts | 2 + .../ui-goal/tests/browser-plugin.spec.tsx | 18 ++- .../ui-goal/tests/goal-command-input.spec.tsx | 134 ++++++++++++++++++ packages/client/ui-goal/tsconfig.json | 3 + pnpm-lock.yaml | 3 + tsconfig.host.json | 1 + 31 files changed, 582 insertions(+), 17 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.md create mode 100644 .agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.zh.md create mode 100644 apps/web/tests/goal-command-presentation.e2e.ts create mode 100644 apps/web/tests/snapshots/goal-command-presentation/ui.expected.md create mode 100644 packages/client/ui-goal/src/client/GoalCommandInputView.module.css create mode 100644 packages/client/ui-goal/src/client/GoalCommandInputView.tsx create mode 100644 packages/client/ui-goal/src/client/goal-command-input.ts create mode 100644 packages/client/ui-goal/tests/goal-command-input.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.i18n.yaml new file mode 100644 index 0000000000..d40fbd1884 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.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-01-goal-command-input-projection.md +2026-08-01-goal-command-input-projection.md: 02836de7ea2d79122d7650d1b37406802ee1489f +2026-08-01-goal-command-input-projection.zh.md: 8c28ba1c90be5942aa526fb9d8d2d8b1f014742e diff --git a/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.md b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.md new file mode 100644 index 0000000000..02836de7ea --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.md @@ -0,0 +1,39 @@ +# Agent Note: Goal command input projection + +Status: implemented + +English | [中文](2026-08-01-goal-command-input-projection.zh.md) + +## Problem + +Human commands execute outside the model turn and persist as `command/run` plus `command/done`. The Web transcript rendered only their result row. On a fresh session, `/goal` therefore cleared the composer and completed successfully while the page stayed on the empty hero; its result became visible only after later conversation content activated Chat. Appending an ordinary `user/message` from the handler would change model-visible history and command semantics. + +## Decision + +The command registry and durable command lifecycle remain unchanged. `command/run` records the parser-owned name, optional verbatim arguments, source, and invocation id; `command/done` records settlement. Neither event carries browser presentation intent. + +The `ui-goal` client plugin registers a Goal-owned Conversation Definition beside the generic command Definition. Both match the same `/goal` `command/run`: the generic Definition retains the durable result row, while the Goal Definition builds a separate `command-input` Chat Node at an earlier fractional anchor. The Goal plugin also registers the keyed React renderer for that Node. Its local component copies only the user bubble's right-aligned geometry and semantic tokens, uses 14px/22px monospace text, and mounts no timestamp, copy, or branch actions. + +`Session.composerPhase` treats visible non-command Chat Nodes as conversation content, so `command-input` activates the current conversation while a generic command row alone does not. The Host `summary.blank` bit remains turn-based, so list hiding and blank-session reuse do not change. + +The Goal Definition derives `/` from the structured run: separator and internal multiline input survive, while the claimed bare form whose arguments contain one space displays `/goal`. A history window containing only `command/done` has no matching Goal Context, so it keeps the generic result row without inventing an input bubble; loading the older run restores both Nodes. + +The model boundary is unchanged. The Goal projection creates no `user/message`, `turn/start`, `step/start`, or `request/header`. Accepted goal mutations reach the model only through the goal domain's existing `` snapshot or clear tombstone, independently of the command-input Node. + +## Verification + +Goal client tests pin the dual Definition output, ordering, other-command exclusion, bare and multiline text, done-only cuts, renderer semantics, disposal, and fresh-session phase selection. The keyless assembled Web scenario submits bare `/goal` in a fresh session with no model adapter, verifies both rows and the absence of model-surface events, then reloads and verifies the persisted transcript. + +## Alternatives considered + +**Append `user/message` in the `/goal` handler.** Rejected because the command would become model input and could trigger or alter a later request. + +**Add presentation intent to the command registry and durable event.** Rejected because one Goal view would widen the generic command interface and make Session, Chat, and every command fixture carry browser presentation state. The existing `command/run` name and arguments already let the composed Goal client reconstruct its own view. + +**Teach the generic command renderer about `/goal`.** Rejected because command-specific view construction belongs to the Goal client plugin. Composing that plugin out must remove the bubble without changing command execution or the generic result row. + +**Render every command input as a user bubble.** Rejected because existing control commands deliberately leave a fresh session on the hero; changing them would broaden interaction semantics without a feature-owned Conversation Definition. + +## Consequences + +One durable `/goal` run feeds two independently owned view Contexts without changing the command capability. Composing `ui-goal` out leaves ordinary command execution and its result row intact. Live tabs and cold reloads agree because both views derive from the same run. A page cut that retains only `command/done` temporarily shows only the result row; if that command is the session's only content, the hero hides the row until an older page restores the run. The session remains list-hidden and reusable until a model turn starts because Host blank semantics remain turn-based. diff --git a/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.zh.md b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.zh.md new file mode 100644 index 0000000000..8c28ba1c90 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-01-goal-command-input-projection.zh.md @@ -0,0 +1,39 @@ +# Agent Note: Goal 命令输入投影 + +Status: implemented + +[English](2026-08-01-goal-command-input-projection.md) | 中文 + +## 问题 + +面向用户的命令在模型轮次之外执行,并持久化为 `command/run` 与 `command/done`。Web transcript(文本记录)此前只渲染结果行。因此,在新会话中,`/goal` 会清空编辑器并成功完成,但页面仍停留在空白 Hero;只有后续对话内容激活 Chat 后,结果才会显示。若处理器追加普通 `user/message`,将改变模型可见历史与命令语义。 + +## 决策 + +命令注册表与持久命令生命周期保持不变。`command/run` 记录由解析器提供的名称、可选的原样参数、来源和调用 id;`command/done` 记录结算。两条事件都不携带浏览器呈现意图。 + +`ui-goal` 客户端插件会在通用命令 Definition 之外注册一个归 Goal 所有的 Conversation Definition。两者都匹配同一条 `/goal` `command/run`:通用 Definition 保留持久结果行,Goal Definition 则在更早的分数锚点构建独立的 `command-input` Chat Node。Goal 插件还为该 Node 注册 keyed React renderer。它的本地组件只复用用户气泡的右对齐几何形态和语义 token,使用 14px/22px 等宽字体文本,并且不挂载时间戳、复制或分支操作。 + +`Session.composerPhase` 把可见的非命令 Chat Node 视为对话内容,因此 `command-input` 会激活当前对话,而仅有通用命令行时不会。Host 的 `summary.blank` 位仍以轮次为基础,因此列表隐藏和空白会话复用保持不变。 + +Goal Definition 根据结构化 run 派生 `/`:分隔符与内部多行输入保持不变;在已认领的裸命令形式中,参数只有一个空格时显示 `/goal`。仅包含 `command/done` 的历史窗口没有匹配的 Goal Context,因此会保留通用结果行,而不会虚构输入气泡;加载包含更早 run 的页面后,两个 Node 都会恢复。 + +模型边界保持不变。Goal 投影不会创建 `user/message`、`turn/start`、`step/start` 或 `request/header`。已接受的 goal 变更只会通过 goal 领域现有的 `` 快照或 clear tombstone 到达模型,与 `command-input` Node 无关。 + +## 验证 + +Goal 客户端测试固定双 Definition 输出、顺序、排除其他命令、裸命令与多行文本、仅含 done 的切分窗口、renderer 语义、资源释放和新会话 phase 选择。无密钥的完整组装 Web 场景在不含模型适配器的新会话中提交裸 `/goal`,验证两行都显示且不存在面向模型的事件,然后重新加载并验证持久化后的 transcript。 + +## 备选方案 + +**在 `/goal` 处理器中追加 `user/message`。**不予采纳,因为该命令会变成模型输入,并可能触发或改变后续请求。 + +**向命令注册表与持久事件添加呈现意图。**不予采纳,因为一个 Goal 视图会扩大通用命令接口,并要求 Session、Chat 和每个命令 fixture(测试前置数据)都携带浏览器呈现状态。现有 `command/run` 的名称和参数已足以让组合后的 Goal 客户端重建自有视图。 + +**让通用命令 renderer 识别 `/goal`。**不予采纳,因为命令专用视图的构建归 Goal 客户端插件所有。在组合中移除该插件后,气泡必须随之消失,且命令执行和通用结果行不能改变。 + +**把每条命令输入都渲染为用户气泡。**不予采纳,因为现有控制命令会有意让新会话停留在 Hero;这样修改会在没有功能自有 Conversation Definition 的情况下扩大交互语义。 + +## 后果 + +一条持久 `/goal` run 会向两个各自独立归属的视图 Context 提供数据,而不改变命令能力。在组合中移除 `ui-goal` 后,普通命令执行及其结果行保持不变。实时标签页与冷重载会得到一致结果,因为两个视图都派生自同一条 run。页面切分只保留 `command/done` 时,会暂时只显示结果行;如果该命令是会话中的唯一内容,Hero 会隐藏该行,直到加载更早页面恢复 run。由于 Host 的 blank 语义仍以轮次为基础,会话在模型轮次开始前仍从列表中隐藏,并且可以复用。 diff --git a/apps/web/tests/goal-command-presentation.e2e.ts b/apps/web/tests/goal-command-presentation.e2e.ts new file mode 100644 index 0000000000..6117fb8ace --- /dev/null +++ b/apps/web/tests/goal-command-presentation.e2e.ts @@ -0,0 +1,123 @@ +// Web e2e: /goal opts its command input into the human transcript while the +// command remains log-only. The shipped composition runs with no model adapter, +// so an accidental turn fails loud in addition to the event-level assertions. +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 { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-commands/types' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, + compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-command-presentation', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL( + './snapshots/goal-command-presentation/ui.expected.md', import.meta.url, +)) +const MODE = webSnapshotMode() + +describe('web e2e: /goal human transcript presentation', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const events: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold() + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) }) + 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 bare input and result from a fresh session without a model turn', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation')) + await expect.poll(() => page.getByText('Into the Unknown', { exact: false }).count(), { + timeout: 15_000, + }).toBe(1) + const input = page.locator('textarea').first() + await input.fill('/goal') + await input.press('Enter') + await expect.poll(() => input.inputValue()).toBe('/goal ') + await input.press('Enter') + + const commandInput = page.locator('[data-command-input]') + await commandInput.waitFor({ timeout: 10_000 }) + await expect.poll(() => commandInput.textContent()).toBe('/goal') + expect(await commandInput.getAttribute('role')).toBe('group') + expect(await commandInput.getAttribute('aria-label')).toBe('Command input') + expect(await commandInput.getByRole('button').count()).toBe(0) + const typography = await commandInput.evaluate((element) => { + const bubble = element.firstElementChild?.firstElementChild + if (!(bubble instanceof HTMLElement)) throw new Error('command input bubble is missing') + const rootStyle = getComputedStyle(element) + const bubbleStyle = getComputedStyle(bubble) + return { + fontFamily: bubbleStyle.fontFamily, + parentFontFamily: rootStyle.fontFamily, + fontSize: bubbleStyle.fontSize, + lineHeight: bubbleStyle.lineHeight, + } + }) + expect(typography).toMatchObject({ fontSize: '14px', lineHeight: '22px' }) + expect(typography.fontFamily).not.toBe(typography.parentFontFamily) + const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' }) + await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1) + expect(await resultRow.getByText('goal', { exact: true }).count()).toBe(1) + await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1) + expect(await page.getByText('Into the Unknown', { exact: false }).count()).toBe(0) + + const run = events.find(event => event.type === 'command/run') + expect(run).toMatchObject({ + type: 'command/run', + data: { name: 'goal', args: ' ', source: { kind: 'user' } }, + }) + expect(events.some(event => event.type === 'command/done')).toBe(true) + expect(events.some(event => event.type === 'user/message')).toBe(false) + expect(events.some(event => event.type === 'turn/start')).toBe(false) + expect(events.some(event => event.type === 'step/start')).toBe(false) + expect(events.some(event => event.type === 'request/header')).toBe(false) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }, 60_000) + + it('reloads the same bubble and result from the persisted command lifecycle', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-command-presentation-reload')) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + + await expect.poll(() => page.locator('[data-command-input]').textContent(), { timeout: 15_000 }).toBe('/goal') + const resultRow = page.locator('[data-variant="others"]').filter({ hasText: 'No goal is currently set.' }) + await expect.poll(() => resultRow.count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => page.locator('[data-phase="active"]').count()).toBe(1) + + const sessions = scaffold.ctx.sessions.list() + expect(sessions).toHaveLength(1) + const persisted = sessions[0]?.events ?? [] + expect(persisted.filter(event => event.type === 'command/run' || event.type === 'command/done') + .map(event => event.type)).toEqual(['command/run', 'command/done']) + expect(persisted.some(event => event.type === 'user/message')).toBe(false) + expect(persisted.some(event => event.type === 'turn/start')).toBe(false) + expect(persisted.some(event => event.type === 'step/start')).toBe(false) + expect(persisted.some(event => event.type === 'request/header')).toBe(false) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }, 90_000) +}) diff --git a/apps/web/tests/snapshots/goal-command-presentation/ui.expected.md b/apps/web/tests/snapshots/goal-command-presentation/ui.expected.md new file mode 100644 index 0000000000..e3f026066f --- /dev/null +++ b/apps/web/tests/snapshots/goal-command-presentation/ui.expected.md @@ -0,0 +1,21 @@ +- banner: + - navigation "Session hierarchy": + - button "workspace" [disabled] + - img + - text: Standard mode + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- group "Command input": /goal +- 'button "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]"': + - img + - img + - text: "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]" +- 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 "Send message" [disabled] diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md index c0ece71be8..737bd5b591 100644 --- a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md @@ -6,6 +6,7 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- group "Command input": /goal 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 - 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"': - img - img diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 9996bdcd0b..49db39a1a5 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -6,6 +6,7 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- group "Command input": /goal Keep the composer context panels aligned - 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"': - img - img diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f96a1f8dd1..6cda040c4e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -70,6 +70,7 @@ "tests/produced-files.e2e.ts", "tests/produced-file-mentions.e2e.ts", "tests/goal-bar.e2e.ts", + "tests/goal-command-presentation.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/subagent-interrupt.e2e.ts", "tests/subagent-interrupt-ui.e2e.ts", diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 9ced9ee90a..09464e24c7 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: 74534ac78a30631c1b6114a6146bce9824843b78 -module-graph.zh.md: bd36e8ef9beaf1055dd7e029cad747748f5cc9db +module-graph.md: f16936e69266cd82b6872aa6bf368e281eba82d5 +module-graph.zh.md: 393648062106b457499acc341d711c0dddf5dea8 diff --git a/docs/module-graph.md b/docs/module-graph.md index 74534ac78a..f16936e692 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1146,6 +1146,7 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_commands pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants pkg_client_ui_plan --> pkg_client_connection @@ -1451,7 +1452,7 @@ flowchart TD | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `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-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`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-task`](../packages/client/ui-task) | `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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index bd36e8ef9b..3936480621 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1148,6 +1148,7 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_conversation pkg_client_ui_goal --> pkg_client_ui_primitives pkg_client_ui_goal --> pkg_client_ui_slots + pkg_client_ui_goal --> pkg_commands pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants pkg_client_ui_plan --> pkg_client_connection @@ -1453,7 +1454,7 @@ flowchart TD | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `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-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-command`](../packages/client/ui-command) | `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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | +| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`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), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`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-task`](../packages/client/ui-task) | `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) | diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3d3c29e757..a58d0960d2 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: 7c835deb58db149710495f97a2553c3de58d99da -README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09 +README.md: 69634d4ca577e9fa5c508a5fb2b50333290154b1 +README.zh.md: 9e03cc1903b5e9dc1d13e07bf8394a6e7aee9209 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 7c835deb58..69634d4ca5 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -33,6 +33,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone, so a cwd-matching unaccounted blank session is never hijacked) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +`Session.composerPhase` treats any visible non-command Chat Node as conversation content, so a client plugin can project durable human input without opening a turn while a window containing only generic command rows retains the Host blank posture. List hiding and blank-session reuse still follow the Host blank bit. A history window that lacks the plugin-owned input Node returns to that blank posture until an older page restores it. + ## Pending queue projection `ConversationSnapshot.queue` is the Host's authoritative transient snapshot of `agent.inbox.nextTurn`; pending next-step steering stays outside this projection. Each row carries its `MessageId`, complete editable text when every content block is text, and a flattened preview. The Host derives whole `session/queue` snapshots from durable `agent/inbox/spliced` mutations and sends a baseline on reconnect; the message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications are not used to reconstruct this projection. `Session.updateQueue()` sends edit/remove operations through Host-side `Inbox.splice()` without optimistic client mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index edf4473bec..9e03cc1903 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -33,6 +33,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd,避免劫持 cwd 匹配但未入账的空白会话),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +`Session.composerPhase` 把任何可见的非命令 Chat Node 视为对话内容,因此客户端插件可以在不打开轮次的情况下投影持久用户输入,而仅包含通用命令行的窗口仍保持 Host blank 状态。列表隐藏和空白会话复用仍遵循 Host blank 位。缺少插件输入 Node 的历史窗口会恢复该空白状态,直到加载更早页面后该 Node 恢复。 + ## 待处理队列投影 `ConversationSnapshot.queue` 是 Host 提供的 `agent.inbox.nextTurn` 权威瞬态快照;待处理的 next-step steering(中途引导)不进入此投影。每行携带其 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。Host 根据持久 `agent/inbox/spliced` 变更派生完整 `session/queue` 快照,并在重连时发送基线;面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知不用于重建该投影。`Session.updateQueue()` 经 Host 侧 `Inbox.splice()` 发送编辑/移除操作,客户端不做乐观变更,因此下一份 Host 快照是唯一可见的提交结果,claim 竞态则会返回 `queue-item-not-found`。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 4397013dab..7972ce5e50 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -324,8 +324,9 @@ export type OpenState = 'cold' | 'loading' | 'open' | 'error' * - `engaging`: a first prompt was attempted, but no accepted turn or other * authoritative activity signal has arrived — the UI keeps the composer * visible through admission and error frames. - * - `active`: the session is non-blank beyond its pending first prompt, is - * running, or owns a pending interaction — the ordinary conversation view. + * - `active`: the session is non-blank beyond its pending first prompt, + * contains visible non-command Chat content, is running, or owns a pending + * interaction — the ordinary conversation view. * * A failed first prompt stays `engaging` (composer + error strip — retry * semantics; returning to the hero would discard the error context). diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index d9bf097e36..3cdac2b1f8 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -741,7 +741,8 @@ export class Session implements SessionFace { ? null : { address: this.address, parentAvailable: this.parentAvailable }, composerPhase: derivePhase( - (!this.blankBit && !this.firstPromptPendingTurn) + hasVisibleConversationContent(chat) + || (!this.blankBit && !this.firstPromptPendingTurn) || this.running || this.pendingCache.value.length > 0, this.promptAttempted, @@ -774,13 +775,18 @@ function conversationInput(entry: HistoryEntry): ConversationEventInput { return { event: entry.event, view: entry.view } } +/** A generic command row alone remains control-plane content; every other visible Chat Node activates the conversation. */ +function hasVisibleConversationContent(chat: ChatSnapshot): boolean { + return chat.order.some(key => chat.nodes.get(key)?.kind !== 'command') +} + /** * The composerPhase judgment — the single site that knows the predicate * (consumers switch on the result, never re-derive). A failed first prompt * stays engaging until an authoritative accepted-turn, running, or pending * signal arrives (retry semantics — see ComposerPhase). * @param hasContent - authoritative non-blank activity beyond a pending first - * prompt, a running turn, or a pending interaction. + * prompt, visible non-command Chat content, a running turn, or a pending interaction. * @param promptAttempted - a prompt was initiated on this session object. * @returns the derived phase. */ diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 0795d9a849..32a9314149 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type {} from '@deepseek-ai/dsh-commands/types' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' import type { @@ -132,7 +133,11 @@ const TEST_EVENT_DEFINITION: ConversationNodeDefinition = { if (context.state === undefined || context.start === undefined) return null return { key: context.key, - kind: 'runtime-test-event', + kind: context.start.event.type === 'command/run' && context.start.event.data.name === 'goal' + ? 'command-input' + : context.start.event.type === 'command/run' || context.start.event.type === 'command/done' + ? 'command' + : 'runtime-test-event', id: context.id, target: 'chat', anchorSeq: context.start.event.seq, @@ -272,6 +277,24 @@ describe('live event path', () => { expect(snapshot.composerPhase).toBe('blank') }) + it('activates a fresh conversation for a command-input View Node without opening a model turn', async () => { + const { session } = await opened([]) + session.handleBlank(true) + const feed = (event: SessionEvent) => { + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) + } + feed(ev.commandRun(0, 'cmd-goal', 'goal', ' ')) + feed(ev.commandDone(1, 'cmd-goal', 'success', 'No goal is currently set.')) + + expect(session.getSnapshot()).toMatchObject({ + blank: true, + composerPhase: 'active', + }) + expect(session.getSnapshot().chat.order.map( + key => session.getSnapshot().chat.nodes.get(key)?.kind, + )).toContain('command-input') + }) + it('publishes animation-frame Definitions once per frame and lets an immediate event supersede the pending frame', async () => { const frames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 2fb8dfdc0e..0dab3370b0 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/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-goal/README.md -README.md: f0446aa0637bc181f7fdc22e5d0d3192e0ac20cf -README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108 +README.md: c79d6f5a68f1b4b40f4b57f5745feeed63a25fcd +README.zh.md: c2d000dd8141a989c67f2e8dc6786ed2b5067a6b diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index f0446aa063..c79d6f5a68 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. +The plugin separately projects each durable `/goal` `command/run` through its own Conversation Definition. It builds a `command-input` Chat Node before the generic command result Node and registers that Node's keyed renderer as a right-aligned 14px/22px monospace user-style bubble with the localized group name `Command input` / `命令输入` and no timestamp, copy, or branch actions. The visible non-command Node activates fresh Chat; reload reconstructs it from the run, while a history window containing only `command/done` keeps only the generic result row. This projection never creates `user/message` or a model turn. + The `/client` exports are the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. ## Model Experience diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 1ad9f50aee..c2d000dd81 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -4,6 +4,8 @@ Goal 界面插件(浏览器端部分):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,经 `ctx.remote.goals` 调用——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并将 Remote 调用的拒绝错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +该插件还会通过自有 Conversation Definition 投影每条持久 `/goal` `command/run`。它在通用命令结果 Node 之前构建一个 `command-input` Chat Node,并为该 Node 注册 keyed renderer;renderer 将其呈现为右对齐、使用 14px/22px 等宽字体的用户样式气泡,使用本地化分组名称 `Command input`/`命令输入`,且不含时间戳、复制或分支操作。可见的非命令 Node 会激活新 Chat;重新加载时会根据 run 重建该 Node,而仅包含 `command/done` 的历史窗口只保留通用结果行。该投影绝不会创建 `user/message` 或模型轮次。 + `/client` 的导出接口包括插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 ## 模型体验 diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 9216af858a..28dfd38d56 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", @@ -65,6 +66,7 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.module.css b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css new file mode 100644 index 0000000000..7bdb83889e --- /dev/null +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.module.css @@ -0,0 +1,25 @@ +.row { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +.stack { + display: flex; + flex-direction: column; + align-items: flex-end; + min-width: 0; + max-width: min(525px, 82%); +} + +.bubble { + max-width: 100%; + padding: 10px 16px; + overflow-wrap: anywhere; + border-radius: 22px; + background: var(--dsw-specific-bubble); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-markdown-code); + white-space: pre-wrap; +} diff --git a/packages/client/ui-goal/src/client/GoalCommandInputView.tsx b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx new file mode 100644 index 0000000000..6d2345042b --- /dev/null +++ b/packages/client/ui-goal/src/client/GoalCommandInputView.tsx @@ -0,0 +1,30 @@ +import { memo } from 'react' +import { MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { GoalCommandInputData } from './goal-command-input.ts' +import css from './GoalCommandInputView.module.css' + +type GoalCommandInputViewProps = + PropsRuntime<'conversation.chat.node', 'command-input'> + & PropsLocale<'goal'> + +/** Right-aligned `/goal` input bubble without ordinary message actions. */ +export const GoalCommandInputView = memo(function GoalCommandInputView({ + node, t, +}: GoalCommandInputViewProps) { + const data: GoalCommandInputData = node.data + return ( +
+
+
+ +
+
+
+ ) +}) diff --git a/packages/client/ui-goal/src/client/goal-command-input.ts b/packages/client/ui-goal/src/client/goal-command-input.ts new file mode 100644 index 0000000000..7d58a5305d --- /dev/null +++ b/packages/client/ui-goal/src/client/goal-command-input.ts @@ -0,0 +1,71 @@ +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type {} from '@deepseek-ai/dsh-commands/types' +import type { + ConversationNodeDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' + +/** Goal-owned human command input projected independently of model messages. */ +export interface GoalCommandInputData { + readonly commandId: CommandId + readonly text: string + readonly time: number +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Human-entered `/goal` command input. */ + 'command-input': GoalCommandInputData + } +} + +interface GoalCommandInputState extends GoalCommandInputData { + readonly seq: number +} + +/** + * Derive the visible command line from its structured durable run. + * @param event - `/goal` command run. + * @returns command text with trailing parser whitespace removed. + */ +export function goalCommandText(event: SessionEvent<'command/run'>): string { + return `/${event.data.name}${(event.data.args ?? '').trimEnd()}` +} + +/** Goal-owned command input projection; the generic command Definition retains the result row. */ +export const goalCommandInputDefinition: ConversationNodeDefinition = { + kind: 'goal-command-input', + target: 'chat', + match: event => event.type === 'command/run' && event.data.name === 'goal' + ? { id: String(event.data.commandId), role: 'start' } + : null, + start: (_context, match) => { + if (match.event.type !== 'command/run') { + throw new Error('goal-command-input start requires command/run') + } + return { + commandId: match.event.data.commandId, + seq: match.event.seq, + time: match.event.time, + text: goalCommandText(match.event), + } + }, + update: context => context.state, + buildViewNode: (context) => { + if (context.state === undefined) return null + return { + key: context.key, + kind: 'command-input', + id: context.id, + target: 'chat', + anchorSeq: context.state.seq - 0.1, + location: context.start?.location ?? { kind: 'unresolved' }, + visibility: 'visible', + data: { + commandId: context.state.commandId, + text: context.state.text, + time: context.state.time, + }, + } + }, +} diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index d9b185b8fd..d66e025ffd 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -19,6 +19,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type { GoalProjection, GoalRef } from '@deepseek-ai/dsh-goal/client' import type { GoalActionResult, GoalBarActions } from './slots.ts' import { GoalDock } from './GoalBar.tsx' +import { GoalCommandInputView } from './GoalCommandInputView.tsx' +import { goalCommandInputDefinition } from './goal-command-input.ts' import { en, zh, type GoalKey } from './locales.ts' export { GoalBar, GoalDock } from './GoalBar.tsx' @@ -35,8 +37,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'goal' -/** Required services: slots for the dock entry, sessions for the projected ref, API for Remote mutations, locale for the copy. */ -export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale'] +/** Required services for the Goal dock, command-input projection, Remote mutations, and copy. */ +export const inject = ['slots', 'sessions', 'remote', 'remote.goals', 'locale', 'conversationEvents'] /** Map one generated Remote call, including synchronous namespace lookup failures, to the fields rendered by the goal strip. */ async function settle(invoke: () => Promise): Promise { @@ -68,8 +70,15 @@ function isRemoteError(value: unknown): value is { readonly code: string; readon * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.conversationEvents.register(goalCommandInputDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-goal: dictionaries') + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', + key: 'command-input', + locale: NS, + }, GoalCommandInputView)) + const sessions = ctx.sessions /** The session's current projected CAS ref, read at verb call time (no staleness fence: the RPC's CAS is the guard). */ diff --git a/packages/client/ui-goal/src/client/locales.ts b/packages/client/ui-goal/src/client/locales.ts index 72c9af658d..5fd6411573 100644 --- a/packages/client/ui-goal/src/client/locales.ts +++ b/packages/client/ui-goal/src/client/locales.ts @@ -6,6 +6,7 @@ export const zh = { 'phase.paused': '已暂停的目标', 'phase.blocked': '受阻的目标', 'objective.aria': '目标内容', + 'commandInput.aria': '命令输入', 'action.save': '保存目标', 'action.cancel': '取消编辑', 'action.pause': '暂停目标', @@ -23,6 +24,7 @@ export const en = { 'phase.paused': 'Paused Goal', 'phase.blocked': 'Blocked Goal', 'objective.aria': 'Goal objective', + 'commandInput.aria': 'Command input', 'action.save': 'Save goal', 'action.cancel': 'Cancel edit', 'action.pause': 'Pause goal', diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 9ead151b65..793a6e3681 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -15,6 +15,7 @@ import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-runtime/src/client/conversation/event-registry.ts' import type { GoalProjection } from '@deepseek-ai/dsh-goal/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -52,6 +53,7 @@ async function bench(options: { } = {}) { const ctx = new Context() const calls: { method: string; args: unknown[] }[] = [] + const conversationEvents = new ConversationEventRegistry(ctx) function answer(method: string, value: T) { return (...args: unknown[]) => { calls.push({ method, args }) @@ -85,7 +87,10 @@ async function bench(options: { }) await ctx.plugin(SlotsService).await() ctx.slots.register({ - name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } }, + name: 'root', children: { + 'conversation.input.dock': { kind: 'list', scope: 'session' }, + 'conversation.chat.node': { kind: 'keyed', scope: 'session' }, + }, } as never, (() => null) as never) ctx.provide('locale', new LocaleService(ctx)) ctx.provide('sessions', { @@ -103,6 +108,7 @@ async function bench(options: { ctx, fiber, calls, + definitions: () => conversationEvents.entries(), remountGoals: () => { activeGoals = goals('remounted-goals') }, unmountGoals: () => { activeGoals = undefined }, entry: () => { @@ -114,15 +120,19 @@ async function bench(options: { inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined, } }, + chatEntry: () => ctx.slots.entries('conversation.chat.node')[0], } } describe('ui-goal browser plugin', () => { - it('registers the GoalBar dock entry with the documented id and order', async () => { + it('registers the GoalBar dock, command input Definition, and keyed Chat renderer', async () => { const b = await bench() await b.fiber.await() expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' }) expect(b.entry()?.inject).toBeTypeOf('function') + expect(b.definitions().map(definition => definition.kind)).toEqual(['goal-command-input']) + expect(b.chatEntry()?.options).toMatchObject({ key: 'command-input' }) + expect(b.chatEntry()?.locale).toBe('goal') }) it('verbs read the CAS ref from the current projected value at call time', async () => { @@ -199,8 +209,12 @@ describe('ui-goal browser plugin', () => { const b = await bench() await b.fiber.await() expect(b.entry()).toBeDefined() + expect(b.chatEntry()).toBeDefined() + expect(b.definitions()).toHaveLength(1) await b.fiber.dispose() expect(b.entry()).toBeUndefined() + expect(b.chatEntry()).toBeUndefined() + expect(b.definitions()).toHaveLength(0) }) }) diff --git a/packages/client/ui-goal/tests/goal-command-input.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.spec.tsx new file mode 100644 index 0000000000..c867e516d6 --- /dev/null +++ b/packages/client/ui-goal/tests/goal-command-input.spec.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +import { cleanup, render, within } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import type { + ChatConversationViewNode, ChatSnapshot, ConversationEventInput, + ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { commandDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/command.ts' +import { chatViewDefinition } from '@deepseek-ai/dsh-client-ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts' +import { GoalCommandInputView } from '../src/client/GoalCommandInputView.tsx' +import { + goalCommandInputDefinition, goalCommandText, +} from '../src/client/goal-command-input.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { + return [commandDefinition, goalCommandInputDefinition] + } + + fallbackEntry(): undefined { + return undefined + } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { + return [chatViewDefinition] + } +} + +function entry(seq: number, type: string, data: unknown): ConversationEventInput { + return { + event: { seq, time: 1_700_000_000_000 + seq, type, data } as ConversationEventInput['event'], + view: undefined, + } +} + +function snapshot(entries: readonly ConversationEventInput[], hasMore = false): ChatSnapshot { + const assembler = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions()) + assembler.replaceWindow(entries, hasMore) + assembler.flush() + const value = assembler.snapshot('chat') as ChatSnapshot | undefined + if (value === undefined) throw new Error('chat view was not registered') + return value +} + +function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined { + return value.nodes.values().find(candidate => candidate.kind === kind) +} + +describe('goal command input projection', () => { + it('builds a separate input Node before the generic command result and restores it on replay', () => { + const run = entry(1, 'command/run', { + commandId: 'command-goal', name: 'goal', args: ' ', source: { kind: 'user' }, + }) + const done = entry(2, 'command/done', { + commandId: 'command-goal', kind: 'success', text: 'No goal is currently set.', + }) + const value = snapshot([run, done]) + + expect(value.order.map(key => value.nodes.get(key)?.kind)).toEqual(['command-input', 'command']) + expect(node(value, 'command-input')).toMatchObject({ + anchorSeq: 0.9, + data: { commandId: 'command-goal', text: '/goal' }, + }) + expect(node(value, 'command')?.data).toMatchObject({ + name: 'goal', args: ' ', outcome: { kind: 'success', text: 'No goal is currently set.' }, + }) + + const doneOnly = snapshot([done], true) + expect(node(doneOnly, 'command-input')).toBeUndefined() + expect(node(doneOnly, 'command')?.data).toMatchObject({ name: null, args: null }) + }) + + it('ignores other commands and preserves internal multiline arguments', () => { + const plan = entry(1, 'command/run', { + commandId: 'command-plan', name: 'plan', args: '', source: { kind: 'user' }, + }) + const goal = entry(2, 'command/run', { + commandId: 'command-goal', name: 'goal', args: '\nfirst line\nsecond line \n', source: { kind: 'user' }, + }) + + expect(goalCommandInputDefinition.match(plan.event)).toBeNull() + expect(goalCommandText(goal.event as SessionEvent<'command/run'>)) + .toBe('/goal\nfirst line\nsecond line') + }) + + it('keeps the Definition total across required interface and window fallback paths', () => { + const run = entry(3, 'command/run', { + commandId: 'command-goal', name: 'goal', source: { kind: 'user' }, + }) + const match = { + ...run, + role: 'start' as const, + location: { kind: 'session' as const }, + } + const state = goalCommandInputDefinition.start({} as never, match, {} as never) + + expect(state.text).toBe('/goal') + expect(goalCommandInputDefinition.update({ state } as never, match)).toBe(state) + expect(goalCommandInputDefinition.buildViewNode!({ state: undefined } as never)).toBeNull() + expect(goalCommandInputDefinition.buildViewNode!({ + key: 'goal-command-input', id: 'command-goal', state, start: undefined, + } as never)).toMatchObject({ location: { kind: 'unresolved' } }) + + const done = entry(4, 'command/done', { commandId: 'command-goal', kind: 'success' }) + expect(() => goalCommandInputDefinition.start({} as never, { + ...done, role: 'start', location: { kind: 'session' }, + } as never, {} as never)).toThrow('goal-command-input start requires command/run') + }) + + it('renders the user-style command bubble without ordinary message actions', () => { + const t = makeTranslate(zh, commonZh) + const props = { + node: { + key: 'goal-command-input:one', + data: { commandId: 'command-goal', text: '/goal ship it', time: 1_700_000_000_000 }, + }, + t, + } as unknown as Parameters[0] + const view = render() + const bubble = view.getByRole('group', { name: '命令输入' }) + + expect(bubble.textContent).toBe('/goal ship it') + expect(within(bubble).queryByRole('button')).toBeNull() + }) +}) diff --git a/packages/client/ui-goal/tsconfig.json b/packages/client/ui-goal/tsconfig.json index 1c89771abf..78335538bb 100644 --- a/packages/client/ui-goal/tsconfig.json +++ b/packages/client/ui-goal/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../ui-slots" }, + { + "path": "../../interaction/commands" + }, { "path": "../../goal/goal" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a18f40488b..7ee1e4e0d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2125,6 +2125,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal diff --git a/tsconfig.host.json b/tsconfig.host.json index b8d20e054b..77ccd0d034 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -54,6 +54,7 @@ "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/feedback-command.e2e.ts", + "apps/web/tests/goal-command-presentation.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", "apps/web/tests/produced-file-mentions.e2e.ts", From 87e3c95027ffb8fbad9877a2fca4b812fccc9b8a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 11 Aug 2026 17:45:11 +0800 Subject: [PATCH 15/56] fix(web): review-round attachment refinements Body-portal the lightbox and toast so transformed ancestors cannot trap their fixed positioning (a lightbox opened from a chat message covered only the chat column); make the toast pointer-transparent; observe the rail element's own size instead of window resizes; consume vertical wheel ticks exclusively via a non-passive listener with LINE/PAGE delta normalization; keep the start position when the rail mounts over an existing draft; honor prefers-reduced-motion for the toast, remove-control, and paging; retry loads through the guarded load effect; note the deliberate promptError re-announce; pin the intake toast in the assembled snapshot; sync the superseded multimodal note and package docs. --- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 10 +-- ...-image-input-and-durable-attachments.zh.md | 10 +-- ...web-attachment-display-alignment.i18n.yaml | 4 +- ...-08-11-web-attachment-display-alignment.md | 4 +- ...-11-web-attachment-display-alignment.zh.md | 4 +- apps/web/tests/image-display.snapshot.ts | 14 +++ .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 3 +- packages/client/ui-attachment/README.zh.md | 13 +-- packages/client/ui-attachment/package.json | 6 +- .../src/AttachmentRail.module.css | 6 ++ .../ui-attachment/src/AttachmentRail.tsx | 85 ++++++++++++++----- .../ui-attachment/src/ImageLightbox.tsx | 10 ++- .../client/ui-attachment/src/MessageImage.tsx | 13 ++- .../tests/attachment-rail.spec.tsx | 59 +++++++++++-- .../client/ui-attachment/tsdown.config.ts | 4 + .../src/client/skeleton/InputBar.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 6 +- .../client/ui-primitives/src/Toast.module.css | 12 +++ packages/client/ui-primitives/src/Toast.tsx | 10 ++- pnpm-lock.yaml | 6 ++ 22 files changed, 218 insertions(+), 73 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 3e3e892063..c24d181e58 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: ea234e557553dda03ef2b707b60d47d3f43eb8f8 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: be478ece73039201a820d804435610faea4cb3fe +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: f1fbcbd29b188505e647265c4c974886c066e936 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a55467cacc412b6f313dd932a0e4868965c039ea diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index ea234e5575..f1fbcbd29b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -18,17 +18,17 @@ Peer products converge on an attachment rail above the editor, but their storage Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references. -Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on double-click. File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups. +Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on a single click (display and interaction specifics superseded in part by the [attachment-display alignment note](2026-08-11-web-attachment-display-alignment.md)). File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups. ### Product behavior - Pasting or dropping one or more supported images adds ordered thumbnails above the textarea without inserting placeholder text. Dragging files over the composer highlights the drop target. - The same resident `InputBar` renders the rail in both blank-session Hero and active-session layouts. The rail is hidden when empty and scrolls horizontally instead of widening the composer. -- Each approximately 72-by-72-pixel thumbnail has a remove action and opens its original draft image on double-click. +- Each 64-by-64-pixel thumbnail carries a hover-revealed remove control inside the card and opens its original draft image on a single click; overflow pages with edge arrows instead of a visible scrollbar. - A prompt may contain text and images or images only. Pure text paste remains native browser behavior; mixed clipboard content inserts its text normally while adding its files to the rail, and file-only paste prevents default browser handling. File drops on the composer always prevent browser navigation and report unsupported files locally. - A failed send restores the complete text and image draft without clobbering text or images added while the request was in flight. Removal, successful send, session-scope disposal, rendered-history disposal, and application disposal revoke the object URLs they own. - Historical user and assistant images use one `MessageImage` control. Inline images preserve intrinsic aspect ratio, do not upscale, and stay within a 240-by-240-pixel box. -- Double-clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus. +- Clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus. - Version one does not override the browser context menu and provides no explicit image-copy action. ### Storage lifecycle and ownership @@ -122,7 +122,7 @@ Base64 crosses JSON-RPC once and is discarded after persistence. The host valida Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. -The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast. The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. @@ -163,7 +163,7 @@ The attachment packages form the interface/implementation side of one capability ### Implementation -The implemented slice includes the attachment seam, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage. +The implemented slice includes the attachment seam, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index be478ece73..a55467cacc 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -18,17 +18,17 @@ Status: implemented 粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 -第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。 +第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持单击预览原图(展示与交互细节部分由[附件展示对齐 Note](2026-08-11-web-attachment-display-alignment.md)取代)。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。 ### 产品行为 - 粘贴或拖放一张或多张受支持的图片后,文本框上方会按顺序显示缩略图,但不会插入占位文本。文件拖入输入区时会高亮放置目标。 - 同一个常驻 `InputBar` 会在空白会话 Hero 和活跃会话布局中渲染附件栏。附件栏为空时隐藏,通过横向滚动避免撑宽输入区。 -- 每个缩略图约为 72 × 72 像素,带有移除操作;双击时打开草稿原图。 +- 每个缩略图为 64 × 64 像素,移除按钮位于卡片内部、悬停时显示;单击打开草稿原图,溢出用两端箭头翻页而非可见滚动条。 - 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴混合的剪贴板内容时,文本会正常插入,文件则同时添加到附件栏;仅粘贴文件时才阻止浏览器的默认处理。在输入区放置文件时总会阻止浏览器导航,并在本地报告不受支持的文件。 - 发送失败时恢复完整的文本与图片草稿,但不会覆盖请求飞行期间新增的文本或图片。移除、发送成功、会话 scope 释放、已渲染历史记录释放和应用释放都会撤销各自持有的对象 URL。 - 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大,并限制在 240 × 240 像素的边界框内。 -- 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 +- 单击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 - 第一版不覆盖浏览器上下文菜单,也不提供明确的图片复制操作。 ### 存储生命周期与归属 @@ -122,7 +122,7 @@ Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规 模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 -宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。 Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 @@ -163,7 +163,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 实现 -已实现的范围包括附件服务边界、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。 +已实现的范围包括附件服务边界、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml index e967409825..3386b9fec0 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.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-11-web-attachment-display-alignment.md -2026-08-11-web-attachment-display-alignment.md: 84d51aada0a463145115f0cebfaf73e9b2bd9e9b -2026-08-11-web-attachment-display-alignment.zh.md: 2676fcea333b54017c37cb4ee85f59ad7c76cfac +2026-08-11-web-attachment-display-alignment.md: 0c41b337d90293525c31afd60aa16bbc3f7cf16c +2026-08-11-web-attachment-display-alignment.zh.md: c2ca9cf546cdb62f3674867ae420e8a4f071f98f diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md index 84d51aada0..0c41b337d9 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md @@ -8,13 +8,15 @@ English | [中文](2026-08-11-web-attachment-display-alignment.zh.md) The web composer's image surfaces missed basic usability (user feedback, issue #2248). The remove control hung outside each 72px thumbnail at `top/right: -6px`, so the rail's `overflow-x` box clipped it and clicks aimed at it often missed; previews opened only on double-click, an affordance nothing advertised except a tooltip; a rail wider than the composer produced a raw horizontal scrollbar inside the capsule; and image-intake rejections plus prompt failures (for example `attachment-error` when the selected model takes no image input) rendered as persistent inline red strips above the card. Every one of these surfaces already has a settled design in DeepSeek Chat that users know: single-click preview, an inside-the-card hover-revealed remove control, hidden-scrollbar arrow paging, and a transient top-center toast. +The first multimodal ship recorded these surfaces in the [web multimodal note](2026-07-22-web-multimodal-image-input-and-durable-attachments.md); this note supersedes its display and interaction specifics (thumbnail geometry, click affordance, error presentation) while its attachment seam, admission, and durability decisions stand. + All of this UI also lived inside `dsh-client-ui-conversation` — the rail inline in the 700-line `InputBar`, the history image and lightbox in `chat/` and `skeleton/` — with no seam that another surface could reuse and nothing enforcing the pure-props discipline the pieces already had. ## Decision Attachment display lives in a new zero-cordis atoms package, `@deepseek-ai/dsh-client-ui-attachment` (`packages/client/ui-attachment`), patterned on `dsh-client-ui-primitives`: `AttachmentRail` (64px/16px-radius thumbnails, single-click `onOpen`, inside-the-card remove control revealed on hover or focus and permanent under `pointer: coarse`, hidden scrollbar with circular edge arrows recomputed from scroll geometry, vertical-wheel horizontal pan clamped to 60px/tick, end-reveal on growth), `MessageImage`/`ImageGallery` (single-click preview), and `ImageLightbox`. Strings arrive as label props; `ui-conversation` bridges its `conversation` dictionary through `src/client/image-labels.ts` and keeps the machine wiring (draft ids, preview state, intake callbacks). The cross-package import is sanctioned exactly because the package is an atoms library, not a client plugin: plugin-to-plugin component imports stay forbidden, and the composer's rail is composer-owned rendering, not a slot. -The transient banner is a `ui-primitives` `Toast` atom (top-center, `role="alert"`, three-second hold then one-second fade, `onDone` unmount, keyed per show so identical repeated messages re-announce). `InputBar` routes both intake rejections (`addImages`'s returned reason) and `promptError` through it, replacing the inline strips; the machine-notice strip is untouched. DeepSeek Chat's source (a local reference copy) provided the target behaviors: its `ImageThumbnailInInput` (64px cards, opacity-transition delete), `ScrollArrows` (sentinel-driven paging), and `useToast` usage. +Both overlays body-portal: the lightbox opened from a chat message sits under transformed ancestors that would trap `position: fixed` in their own box (the backdrop covered only the chat column), so `ImageLightbox` and `Toast` render through `createPortal(document.body)` and cover the viewport from every opener. The transient banner is a `ui-primitives` `Toast` atom (top-center, `role="alert"`, three-second hold then one-second fade, `onDone` unmount, keyed per show so identical repeated messages re-announce). `InputBar` routes both intake rejections (`addImages`'s returned reason) and `promptError` through it, replacing the inline strips; the machine-notice strip is untouched. DeepSeek Chat's source (a local reference copy) provided the target behaviors: its `ImageThumbnailInInput` (64px cards, opacity-transition delete), `ScrollArrows` (sentinel-driven paging), and `useToast` usage. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md index 2676fcea33..c2ca9cf546 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md @@ -8,13 +8,15 @@ Status: implemented Web 输入框的图片界面缺乏基本可用性(用户反馈,issue #2248)。删除按钮以 `top/right: -6px` 挂在 72px 缩略图外侧,被附件栏的 `overflow-x` 盒子裁切,点击经常落空;预览只能双击打开,除了 tooltip 没有任何提示这个操作;附件栏超出输入框宽度时在胶囊内部直接出现原生横向滚动条;图片接收被拒和发送失败(例如所选模型不支持图片输入时的 `attachment-error`)以常驻的内联红条显示在卡片上方。这些界面在 DeepSeek Chat 里都有用户熟悉的既定设计:单击预览、卡片内部悬停显示的删除按钮、隐藏滚动条的箭头翻页、顶部居中的短时 toast。 +首个多模态版本把这些界面记录在[Web 多模态 Note](2026-07-22-web-multimodal-image-input-and-durable-attachments.md)中;本 Note 取代其中的展示与交互细节(缩略图几何、点击方式、错误呈现),其附件服务边界、准入与持久化决策继续有效。 + 这些 UI 还全部住在 `dsh-client-ui-conversation` 里——附件栏内联在 700 行的 `InputBar` 中,历史图片和灯箱分散在 `chat/` 与 `skeleton/`——没有其他界面可复用的接缝,纯 props 的纪律也无从约束。 ## 决定 附件展示落位到新的零 cordis 原子组件包 `@deepseek-ai/dsh-client-ui-attachment`(`packages/client/ui-attachment`),模式照 `dsh-client-ui-primitives`:`AttachmentRail`(64px、16px 圆角缩略图,单击 `onOpen`,卡片内部的删除按钮悬停或聚焦显示、`pointer: coarse` 下常显,隐藏滚动条配两端圆形箭头并依滚动几何重算,纵向滚轮转横向平移且单次钳制 60px,新增条目滚到栏尾),`MessageImage`/`ImageGallery`(单击预览),以及 `ImageLightbox`。文案经 label props 传入;`ui-conversation` 通过 `src/client/image-labels.ts` 桥接 `conversation` 词典,并保留状态机接线(草稿 id、预览状态、接收回调)。跨包 import 之所以是被允许的路径,正因为它是原子组件库而非 client 插件:插件之间仍禁止互相 import 组件,且附件栏是输入框自有的渲染,不是插槽。 -短时横幅是 `ui-primitives` 的 `Toast` 原子(顶部居中,`role="alert"`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。 +两个浮层都 portal 到 body:从聊天消息打开的灯箱位于带 transform 的祖先之下,`position: fixed` 会被困在祖先的盒子里(遮罩只盖住聊天列),因此 `ImageLightbox` 与 `Toast` 经 `createPortal(document.body)` 渲染,从任何打开位置都覆盖整个视口。短时横幅是 `ui-primitives` 的 `Toast` 原子(顶部居中,`role="alert"`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。 ## 备选方案 diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 9f22cb55f7..2d2dda42d6 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -133,4 +133,18 @@ it('accepts pasted images into the composer rail in order and removes them', asy await waitFor(() => { expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() }) + + // An unsupported file announces a transient toast (the inline strip is + // gone) and the banner dismisses itself after its hold-and-fade lifetime. + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'text/plain', getAsFile: () => new File(['x'], 'notes.txt', { type: 'text/plain' }) }], + getData: () => '', + }, + }) + const toast = await screen.findByRole('alert') + expect(toast.textContent).toContain('Unsupported image format: text/plain') + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeNull() + }, { timeout: 6_000 }) }) diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 4a66c5ebe6..03417393c9 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-attachment/README.md -README.md: a0a410532a631f243c3dfef7debfc6ce0bb0699e -README.zh.md: 4d8ae638a30dc37d89531f15cf3c541d8e09f8f6 +README.md: 7e67a0064f5611ac814a9d489db0d4cc471309e1 +README.zh.md: bf44f6a42db0e1a2c06e1f42133918150b123c2e diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index a0a410532a..7e67a0064f 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -6,7 +6,7 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac ## Attachment rail -`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling, and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and window resizes. A vertical wheel pans the rail horizontally with per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist. +`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). A vertical wheel pans the rail horizontally through a non-passive listener that consumes the event — the same tick never also scrolls the conversation — with LINE/PAGE deltas normalized to pixels and per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist. ## Message images and the lightbox @@ -24,3 +24,4 @@ None; this package neither assembles nor sends a provider request. - **Images only** — non-image files have no rail card or history renderer yet; DeepSeek Chat-style file cards and upload-progress states wait until the composer accepts non-image attachments. - **No zoom or download in the lightbox** — the preview renders the original at fit-to-viewport size only. +- **The lightbox does not trap focus** — it sets `aria-modal` and restores focus on close, but Tab can reach the page behind it (behavior carried over from the pre-package component). diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 4d8ae638a3..bf44f6a42d 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -6,21 +6,22 @@ ## 附件栏 -`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动,箭头的显隐在滚动、条目数量变化和窗口尺寸变化时依据滚动几何重算。纵向滚轮转为横向平移,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。 +`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动(`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算(rail 元素上的 ResizeObserver,因此侧栏、面板的宽度变化也计入,不只是窗口尺寸变化)。纵向滚轮经非 passive 监听器转为横向平移并独占消费该事件,同一次滚动不会同时滚动会话记录;LINE/PAGE 单位的增量先归一化为像素,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。 ## 消息图片与灯箱 `MessageImage` 渲染一张持久化历史图片,长边收敛到 240px,经持有方的 `ImageLoader` 加载会话授权 URL;加载失败渲染显式重试按钮,加载完成后单击打开 `ImageLightbox`(加载中的点击被忽略)。`ImageGallery` 将一条消息的图片包为一个对齐的弹性分组(用户消息 `end`,助手消息 `start`),空列表不渲染。`ImageLightbox` 是文档级模态预览,按 Escape、按下遮罩或点关闭按钮均可关闭,卸载时将焦点还给打开者。 -## Model Experience +## 模型体验 -None, as the package renders pure React atoms in the browser; nothing here reaches a model request. +无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 -#### KV Cache effect +#### KV Cache 影响 -None; this package neither assembles nor sends a provider request. +无;该包既不组装也不发送提供方请求。 -## Known Limitations and Deferred Work +## 已知限制与暂缓事项 - **仅支持图片** — 非图片文件尚无附件栏卡片与历史渲染;DeepSeek Chat 风格的文件卡片和上传进度状态等输入框接受非图片附件后再做。 - **灯箱无缩放与下载** — 预览仅以适配视口的尺寸渲染原图。 +- **灯箱不锁定焦点** — 它设置 `aria-modal` 并在关闭时归还焦点,但 Tab 仍可移动到背后的页面(沿袭入包前组件的行为)。 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 3174f4c8b9..e894a42196 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -30,12 +30,14 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "clsx": "^2.0.0", - "react": "^18.2.0" + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1" + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-attachment/src/AttachmentRail.module.css b/packages/client/ui-attachment/src/AttachmentRail.module.css index 5e53374af2..ac8968f777 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.module.css +++ b/packages/client/ui-attachment/src/AttachmentRail.module.css @@ -81,6 +81,12 @@ } } +@media (prefers-reduced-motion: reduce) { + .remove { + transition: none; + } +} + .arrow { position: absolute; top: 50%; diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx index db83184efc..22ecf39147 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.tsx +++ b/packages/client/ui-attachment/src/AttachmentRail.tsx @@ -2,7 +2,6 @@ * by edge arrows, hover-revealed per-item remove, single-click open. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { WheelEvent } from 'react' import clsx from 'clsx' import { IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseFill14, @@ -33,15 +32,31 @@ export interface AttachmentRailLabels { scrollRight: string } +/** Approximate pixels per wheel step for `deltaMode` LINE deltas (Firefox + * notch wheels report lines, not pixels). */ +const WHEEL_LINE_PX = 16 + +/** Smooth paging unless the user asked for reduced motion. */ +function pageBehavior(): ScrollBehavior { + // jsdom (the unit lane) implements no matchMedia despite lib.dom's + // non-optional typing; the optional call keeps that lane on the default. + // oxlint-disable-next-line typescript/no-unnecessary-condition + return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' +} + /** * Horizontal thumbnail rail over the caller's draft attachments. * * The rail scrolls with its scrollbar hidden; overflow is announced by edge * arrows recomputed from scroll geometry on scroll, item-count changes, and - * window resizes. A vertical wheel pans horizontally, a newly added item is - * revealed at the rail's end, and each thumbnail opens on a single click while - * its remove control sits inside the card and reveals on hover or focus. - * The owner decides mounting; it renders the rail only while items exist. + * rail size changes (a ResizeObserver on the rail element, so sidebar or + * panel resizes count, not only window resizes). A vertical wheel pans the + * rail horizontally and is consumed exclusively (non-passive listener), a + * newly added item is revealed at the rail's end while a rail that mounts + * over an existing draft keeps its start position, and each thumbnail opens + * on a single click while its remove control sits inside the card and + * reveals on hover or focus. The owner decides mounting; it renders the rail + * only while items exist. * * @param props.items - resolved thumbnails in draft order. * @param props.labels - rail-level strings (group name, open tooltip, arrows). @@ -56,7 +71,10 @@ export function AttachmentRail({ items, labels, on onRemove: (item: T) => void }) { const railRef = useRef(null) - const countRef = useRef(0) + // null marks the first layout pass: a rail that MOUNTS over an existing + // draft (session switch back to held images) is initial display, not + // growth, and must not jump to the end. + const countRef = useRef(null) const [edges, setEdges] = useState({ left: false, right: false }) const updateEdges = useCallback(() => { const el = railRef.current @@ -68,16 +86,51 @@ export function AttachmentRail({ items, labels, on setEdges(prev => prev.left === left && prev.right === right ? prev : { left, right }) }, []) useLayoutEffect(() => { - const grew = items.length > countRef.current + const grew = countRef.current !== null && items.length > countRef.current countRef.current = items.length const el = railRef.current + /* v8 ignore next -- defensive: the rail div renders unconditionally, so the layout effect always finds it. */ + if (el === null) return // A newly added attachment lands at the rail's end: reveal it. - if (grew && el !== null) el.scrollLeft = el.scrollWidth - el.clientWidth + if (grew) el.scrollLeft = el.scrollWidth - el.clientWidth updateEdges() }, [items.length, updateEdges]) useEffect(() => { - window.addEventListener('resize', updateEdges) - return () => { window.removeEventListener('resize', updateEdges) } + const el = railRef.current + /* v8 ignore next -- defensive: the rail div renders unconditionally, so the mount effect always finds it. */ + if (el === null) return + // The rail's width follows the composer, which resizes with sidebars and + // panels, not only the window — observe the element itself. jsdom (the + // unit lane) implements no ResizeObserver; every browser gets the + // subscription. + let disconnect = (): void => {} + if (typeof ResizeObserver !== 'undefined') { + const observer = new ResizeObserver(updateEdges) + observer.observe(el) + disconnect = () => { observer.disconnect() } + } + // A vertical wheel pans the rail horizontally and is consumed: without + // preventDefault the same tick would also scroll the conversation behind + // the composer. React's root wheel listener is passive, so the exclusive + // conversion needs this manually attached non-passive listener. LINE and + // PAGE deltas (Firefox notch wheels) are normalized to pixels before the + // per-tick clamp that keeps a fast wheel followable. + const onWheel = (event: globalThis.WheelEvent): void => { + if (event.deltaX !== 0 || event.deltaY === 0) return + const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? WHEEL_LINE_PX + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1 + event.preventDefault() + el.scrollBy({ + left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60), + behavior: 'auto', + }) + } + el.addEventListener('wheel', onWheel, { passive: false }) + return () => { + disconnect() + el.removeEventListener('wheel', onWheel) + } }, [updateEdges]) const page = (direction: -1 | 1): void => { const el = railRef.current @@ -85,16 +138,7 @@ export function AttachmentRail({ items, labels, on if (el === null) return // One viewport minus a card keeps the last visible thumbnail as context; // the floor keeps narrow rails paging a useful distance. - el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: 'smooth' }) - } - // A vertical wheel pans the rail horizontally (trackpads pan natively via - // deltaX); per-tick travel is clamped so a fast notch wheel stays followable. - const onWheel = (event: WheelEvent): void => { - if (event.deltaX !== 0 || event.deltaY === 0) return - event.currentTarget.scrollBy({ - left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY), 60), - behavior: 'auto', - }) + el.scrollBy({ left: direction * Math.max(el.clientWidth - 64, 200), behavior: pageBehavior() }) } return (
@@ -114,7 +158,6 @@ export function AttachmentRail({ items, labels, on role="group" aria-label={labels.group} onScroll={updateEdges} - onWheel={onWheel} > {items.map(item => (
diff --git a/packages/client/ui-attachment/src/ImageLightbox.tsx b/packages/client/ui-attachment/src/ImageLightbox.tsx index dcf01bbc41..0207ee5a53 100644 --- a/packages/client/ui-attachment/src/ImageLightbox.tsx +++ b/packages/client/ui-attachment/src/ImageLightbox.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' import css from './ImageLightbox.module.css' /** Lightbox strings the owner resolves from its own locale namespace. */ @@ -12,7 +13,9 @@ export interface ImageLightboxLabels { /** * Document-level original-image preview opened by clicking a thumbnail. * Closes on Escape, backdrop press, or the close control, and restores focus - * to the opener on unmount. + * to the opener on unmount. Rendered through a body portal: an opener inside + * a transformed or filtered ancestor would otherwise trap the fixed backdrop + * in that ancestor's box instead of covering the viewport. * * @param props.src - the original image URL. * @param props.alt - the image's alt text. @@ -42,7 +45,7 @@ export function ImageLightbox({ src, alt, labels, onClose }: { } }, [onClose]) - return ( + return createPortal(
{alt} -
+
, + document.body, ) } diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index 943d1fd158..15420c9569 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -40,24 +40,23 @@ export function MessageImage({ attachment, load, labels }: { const [src, setSrc] = useState(null) const [error, setError] = useState(false) const [open, setOpen] = useState(false) + // Retry re-arms the one load effect below, so every attempt — first load or + // retry — runs under the same liveness guard and the same reset. + const [attempt, setAttempt] = useState(0) + const request = useCallback(() => { setAttempt(a => a + 1) }, []) const close = useCallback(() => { setOpen(false) }, []) const size = useMemo(() => { const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height) return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) } }, [attachment.height, attachment.width]) - const request = useCallback(() => { - setError(false) - setSrc(null) - void load(attachment).then(setSrc).catch(() => { setError(true) }) - }, [attachment, load]) - useEffect(() => { let live = true setError(false) + setSrc(null) void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) return () => { live = false } - }, [attachment, load]) + }, [attachment, load, attempt]) const label = attachment.name ?? labels.image if (error) return diff --git a/packages/client/ui-attachment/tests/attachment-rail.spec.tsx b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx index de468bd6ce..210b8829c9 100644 --- a/packages/client/ui-attachment/tests/attachment-rail.spec.tsx +++ b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx @@ -1,15 +1,32 @@ // @vitest-environment jsdom // AttachmentRail behavior in the jsdom lane: item rendering and callbacks, // arrow paging over stubbed scroll geometry (jsdom lays nothing out), the -// vertical-wheel pan, and the new-item end reveal. +// exclusive vertical-wheel pan, and the new-item end reveal. -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' import { AttachmentRail } from '../src/AttachmentRail.tsx' import type { AttachmentRailItem, AttachmentRailLabels } from '../src/AttachmentRail.tsx' afterEach(cleanup) +// jsdom implements no ResizeObserver; the stub records instances so a test +// can drive the size-change recompute path. +const observers: { callback: ResizeObserverCallback; observed: Element[] }[] = [] +beforeEach(() => { + observers.length = 0 + vi.stubGlobal('ResizeObserver', class { + observed: Element[] = [] + constructor(callback: ResizeObserverCallback) { + observers.push({ callback, observed: this.observed }) + } + + observe(el: Element) { this.observed.push(el) } + disconnect() { this.observed.length = 0 } + }) +}) +afterEach(() => { vi.unstubAllGlobals() }) + const labels: AttachmentRailLabels = { group: '待发送图片', open: '查看原图', @@ -78,34 +95,58 @@ describe('AttachmentRail', () => { expect(view.getByLabelText('向右滚动图片')).toBeTruthy() }) - it('shows both arrows mid-scroll and recomputes on window resize', () => { + it('shows both arrows mid-scroll and recomputes when the rail itself resizes', () => { const view = render( , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { setScrollLeft } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) setScrollLeft(100) - fireEvent(window, new Event('resize')) + // The component observes the rail element, not the window: a sidebar or + // panel resize reaches it through the ResizeObserver callback. + expect(observers.at(-1)?.observed).toContain(rail) + act(() => { observers.at(-1)!.callback([], undefined as never) }) expect(view.getByLabelText('向左滚动图片')).toBeTruthy() expect(view.getByLabelText('向右滚动图片')).toBeTruthy() }) - it('pans horizontally on a vertical wheel with clamped travel', () => { + it('pans horizontally on a vertical wheel, consuming the event, with clamped normalized travel', () => { const view = render( , ) const rail = view.getByRole('group', { name: '待发送图片' }) const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) - fireEvent.wheel(rail, { deltaY: 30 }) + // Converted ticks are consumed (preventDefault): fireEvent returns false. + expect(fireEvent.wheel(rail, { deltaY: 30 })).toBe(false) expect(scrollBy).toHaveBeenCalledWith({ left: 30, behavior: 'auto' }) fireEvent.wheel(rail, { deltaY: 500 }) expect(scrollBy).toHaveBeenCalledWith({ left: 60, behavior: 'auto' }) fireEvent.wheel(rail, { deltaY: -500 }) expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' }) + // Firefox notch wheels report lines; a page-mode wheel reports viewports. + fireEvent.wheel(rail, { deltaY: 2, deltaMode: WheelEvent.DOM_DELTA_LINE }) + expect(scrollBy).toHaveBeenCalledWith({ left: 32, behavior: 'auto' }) + fireEvent.wheel(rail, { deltaY: -1, deltaMode: WheelEvent.DOM_DELTA_PAGE }) + expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' }) // A trackpad pan (deltaX) and a zero-delta wheel keep native behavior. - fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 }) + expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(true) fireEvent.wheel(rail, { deltaY: 0 }) - expect(scrollBy).toHaveBeenCalledTimes(3) + expect(scrollBy).toHaveBeenCalledTimes(5) + }) + + it('pages instantly under a reduced-motion preference, smoothly otherwise', () => { + for (const [matches, behavior] of [[true, 'auto'], [false, 'smooth']] as const) { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList)) + const view = render( + , + ) + const rail = view.getByRole('group', { name: '待发送图片' }) + const { scrollBy } = stubGeometry(rail, { scrollWidth: 400, clientWidth: 200 }) + fireEvent.scroll(rail) + fireEvent.click(view.getByLabelText('向右滚动图片')) + expect(scrollBy).toHaveBeenCalledWith({ left: 200, behavior }) + view.unmount() + } }) it('reveals the rail end when an item is added, not when one is removed', () => { diff --git a/packages/client/ui-attachment/tsdown.config.ts b/packages/client/ui-attachment/tsdown.config.ts index 2ffa80a8d1..d8c37d8a2c 100644 --- a/packages/client/ui-attachment/tsdown.config.ts +++ b/packages/client/ui-attachment/tsdown.config.ts @@ -1,5 +1,9 @@ import { clientOnly } from '../tsdown.client.ts' +// TODO(client-atoms): verbatim copy of ui-primitives/tsdown.config.ts (only +// the package differs). On a third atoms package, extract a shared css-stub +// client-library preset in packages/client/tsdown.client.ts instead of a +// fourth copy. /** * ui-attachment is browser-only, but its lib bundle IS imported under plain * Node because the web shell is a lib (dsh-client-web's lib chain reaches diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 555177e6f6..b6b60b9053 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -82,7 +82,9 @@ export function InputBar({ const dismissToast = useCallback(() => { setToast(null) }, []) // Prompt failures are ordinary failures (no create/attach transaction exists // anymore): the toast announces promptError, the draft stays in the machine, - // and the user resubmits. + // and the user resubmits. A remount over a session whose machine still holds + // an unresolved promptError deliberately re-announces it once — the failure + // is still pending, and a transient banner is its only surface. useEffect(() => { if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`) }, [promptError, showToast]) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 084a3a48e5..a766781b5f 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -979,10 +979,12 @@ describe('strips and variants', () => { vi.useFakeTimers() try { const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } }) - expect(send.view.container.querySelector('[role="alert"]')?.textContent).toContain('boom (agent-busy)') + // The toast body-portals (transformed ancestors must not trap it), so + // queries go through the view's document-bound helpers. + expect(send.view.getByRole('alert').textContent).toContain('boom (agent-busy)') expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull() act(() => { vi.advanceTimersByTime(4000) }) - expect(send.view.container.querySelector('[role="alert"]')).toBeNull() + expect(send.view.queryByRole('alert')).toBeNull() } finally { vi.useRealTimers() } diff --git a/packages/client/ui-primitives/src/Toast.module.css b/packages/client/ui-primitives/src/Toast.module.css index e4dbdf6bea..6c5fecb1d1 100644 --- a/packages/client/ui-primitives/src/Toast.module.css +++ b/packages/client/ui-primitives/src/Toast.module.css @@ -11,6 +11,10 @@ /* Above the 1000 the image lightbox backdrop uses: a failure reported while a preview is open must stay readable. */ z-index: 1100; + /* Purely an announcement: it must never intercept clicks — in particular + after the CSS fade finished while a throttled background-tab timer has + not yet unmounted the still-hit-testable fixed element. */ + pointer-events: none; display: flex; align-items: center; gap: 10px; @@ -56,3 +60,11 @@ opacity: 0; } } + +/* Reduced motion drops the slide-in; the delayed fade (an opacity change, + not movement) still ends the banner before the timed unmount. */ +@media (prefers-reduced-motion: reduce) { + .toast { + animation: dsh-toast-fade 1000ms ease 3000ms forwards; + } +} diff --git a/packages/client/ui-primitives/src/Toast.tsx b/packages/client/ui-primitives/src/Toast.tsx index 37352cb460..1d9a3b24ce 100644 --- a/packages/client/ui-primitives/src/Toast.tsx +++ b/packages/client/ui-primitives/src/Toast.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' import css from './Toast.module.css' /** Full-opacity hold before the fade starts. Must agree with the stylesheet's @@ -12,7 +13,9 @@ const FADE_MS = 1000 * Transient top-center banner: slides in, holds at full opacity, fades out, * then reports done so the owner can unmount it. Re-showing the same text * restarts the cycle when the owner remounts the component (key it by a - * per-show sequence). + * per-show sequence). Rendered through a body portal so an owner inside a + * transformed or filtered ancestor cannot trap the fixed banner in that + * ancestor's box. * * @param props.text - resolved banner copy; the owner passes localized text. * @param props.icon - optional leading glyph (e.g. a warning icon). @@ -28,10 +31,11 @@ export function Toast({ text, icon, onDone }: { const timer = setTimeout(onDone, HOLD_MS + FADE_MS) return () => { clearTimeout(timer) } }, [onDone]) - return ( + return createPortal(
{icon !== undefined && {icon}} {text} -
+
, + document.body, ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb80f707d2..c390556f80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1951,6 +1951,9 @@ importers: react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1961,6 +1964,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) packages/client/ui-command: dependencies: 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 16/56] 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 17/56] 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 18/56] 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 19/56] 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 f98a95023d98e8db6cee7ffca597922afd85f367 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 17:50:37 +0800 Subject: [PATCH 20/56] Route documentation home to quick start --- ...13-documentation-site-projection.i18n.yaml | 4 +-- ...026-07-13-documentation-site-projection.md | 2 +- ...-07-13-documentation-site-projection.zh.md | 2 +- ...-07-22-product-first-root-readme.i18n.yaml | 4 +-- .../2026-07-22-product-first-root-readme.md | 4 +-- ...2026-07-22-product-first-root-readme.zh.md | 4 +-- ...11-quickstart-documentation-home.i18n.yaml | 6 ++++ ...026-08-11-quickstart-documentation-home.md | 31 +++++++++++++++++++ ...-08-11-quickstart-documentation-home.zh.md | 31 +++++++++++++++++++ docs/user/index.i18n.yaml | 4 +-- docs/user/index.md | 24 +++----------- docs/user/index.zh.md | 24 +++----------- scripts/project-doc-site.spec.ts | 17 ++++++++-- 13 files changed, 105 insertions(+), 52 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md create mode 100644 .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 36f6181c46..b2bed645ba 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: d9af915754fa6a1df51a27d18d412597472aaa73 -2026-07-13-documentation-site-projection.zh.md: 7d7b4752b8f27d55aae8426a7dc001ce4340e661 +2026-07-13-documentation-site-projection.md: d6c7daf1929c93f61a65e9609b283aa889a0df56 +2026-07-13-documentation-site-projection.zh.md: 45c3ebf68b26be062fd827019d69d33cac346c86 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index d9af915754..d6c7daf192 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -16,7 +16,7 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g `scripts/project-doc-site.ts` projects the manifest into the ignored `website/.generated/` directory before VitePress starts or builds. The generated tree follows public routes so VitePress navigation, locale detection, and local search share the same route vocabulary. Each page receives an `editSource` frontmatter field pointing to its canonical repository file; the edit-link callback reads only that page data, so public URLs remain independent of the source layout. -Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. +Locale home projections retain only the canonical YAML frontmatter. The repository-facing body keeps its H1 and bilingual source links, while the frontmatter implements the [locale-preserving quick-start redirect](../simplification/2026-08-11-quickstart-documentation-home.md) and the site navigation owns locale switching. The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a source link under the public `deepseek-ai/deepseek-harness-sdk` home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 7d7b4752b8..45c3ebf68b 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -16,7 +16,7 @@ Status: implemented 在 VitePress 启动或构建之前,`scripts/project-doc-site.ts` 会把 manifest 投影到被忽略的 `website/.generated/` 目录。生成目录树遵循公开路由,使 VitePress 导航、locale 检测和本地搜索使用同一套路由命名。每个页面都会获得一个指向其权威仓库文件的 `editSource` frontmatter 字段;编辑链接回调只读取该页面的数据,因此公开 URL 与源文件布局彼此独立。 -各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 +各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文保留其 H1 和双语源文件链接;frontmatter 实现[保持 locale 不变的快速开始重定向](../simplification/2026-08-11-quickstart-documentation-home.md),网站导航负责切换 locale。 投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成公开 `deepseek-ai/deepseek-harness-sdk` 主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml index d85aa1fe0c..407247314c 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md -2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e -2026-07-22-product-first-root-readme.zh.md: 8ef6f4b99ca2c935183a225b6357d2d128edb3b0 +2026-07-22-product-first-root-readme.md: bd7fa1458fdef120f9e99b7d6af2872f4be55216 +2026-07-22-product-first-root-readme.zh.md: 5521537e9c8daed8cb2eb494389ff3f0d1eec4d2 diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md index 32542a4501..bd7fa1458f 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md @@ -16,7 +16,7 @@ A note before installation thanks internal testers, states that features and exp The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The installed TUI remains the single `dsh` command; the Web instructions build the active checkout before running `dsh web`, and custom or reused checkout paths stay explicit. These launch paths must remain executable through a real PTY and a production build/HTTP smoke, respectively. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, settings, credentials, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it. -Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps its separate user-guide landing page. +Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps a separate [quick-start entry route](../simplification/2026-08-11-quickstart-documentation-home.md) instead of presenting another product landing page. ## Alternatives considered @@ -26,7 +26,7 @@ Detailed package and service inventories remain at their owning documentation. T **Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides. -**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs. +**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs. The documentation root sends readers to quick start instead. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md index 8ef6f4b99c..5521537e9c 100644 --- a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md @@ -16,7 +16,7 @@ Status: implemented 用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建当前检出,再运行 `dsh web`,并明确处理自定义或复用的检出路径。这两条启动路径必须分别能在真实 PTY 与生产构建/HTTP 冒烟中原样执行。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、设置、凭据、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。 -包与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。 +包与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站保留独立的[快速开始入口路由](../simplification/2026-08-11-quickstart-documentation-home.md),不另行呈现产品首页。 ## 考虑过的替代方案 @@ -26,7 +26,7 @@ Status: implemented **使用包含截图、徽章和重复教程的长篇营销页面。** 富媒体能够展示稳定的产品使用路径,但其内容会独立于命令和源码约定而逐渐陈旧。根 README 保持紧凑,并链接到可运行示例和各自维护的指南。 -**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向产品和开发者的入口在导航和维护需求上并不相同。 +**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向产品和开发者的入口在导航和维护需求上并不相同。文档根路由则将读者引导至快速开始。 ## 结果 diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml new file mode 100644 index 0000000000..876a11fb0c --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.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/simplification/2026-08-11-quickstart-documentation-home.md +2026-08-11-quickstart-documentation-home.md: 3fd98843fc0e3e09fc1f4a5623729511aba98def +2026-08-11-quickstart-documentation-home.zh.md: 2e3890f8586488b5a94b236dad595bd23514ab2a diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md new file mode 100644 index 0000000000..3fd98843fc --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md @@ -0,0 +1,31 @@ +# Agent Note: Route documentation roots to quick start + +Status: implemented + +English | [中文](2026-08-11-quickstart-documentation-home.zh.md) + +## Problem + +A separate documentation landing page duplicates product positioning and feature summaries owned by the product landing page. Those parallel claims require synchronization and review without helping readers reach technical instructions. + +## Decision + +Each locale root is a redirect page. `/` sends readers to `./guide/quickstart`, and `/en/` resolves the same relative target to `/en/guide/quickstart`. The relative target preserves the configured `DOCS_BASE` when the site is hosted below an origin path. + +`docs/user/index.md` and `docs/user/index.zh.md` own the redirect as VitePress frontmatter. The [documentation-site projector](../process/2026-07-13-documentation-site-projection.md) publishes only that frontmatter for locale homes, so the canonical Markdown retains its bilingual switcher without rendering a second landing page. The projector test verifies that both locale roots use the same locale-relative quick-start target. + +Product positioning and feature summaries stay outside the documentation site. Guide, development, reference, search, and locale navigation remain available from the quick-start page. + +## Alternatives considered + +**Keep a documentation hero and synchronize its wording.** This preserves a promotional entry page but creates a second product narrative whose claims and terminology can drift from the product landing page. + +**Render a documentation index at the root.** An index repeats the navigation already provided by the site and inserts another choice before the first actionable guide. + +**Copy quick-start content to each locale root.** Two public routes would then own the same tutorial and require another synchronization mechanism. + +**Use origin-absolute redirect targets.** Paths such as `/guide/quickstart` ignore `DOCS_BASE` and fail when the documentation site is hosted below an origin path. + +## Consequences + +Readers entering either locale root immediately reach the quick-start tutorial in that locale. The documentation site gives up a promotional home surface, while the product landing page remains the single owner of positioning and feature summaries. The stable root routes remain valid entry points, and quick-start content retains one canonical source. diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md new file mode 100644 index 0000000000..2e3890f858 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 将文档根路由指向快速开始 + +Status: implemented + +[English](2026-08-11-quickstart-documentation-home.md) | 中文 + +## 问题 + +单独的文档首页会重复产品首页所维护的产品定位和功能摘要。这些重复声明需要同步与评审,却不能帮助读者查阅技术操作说明。 + +## 决策 + +每个 locale 根路由都是重定向页面。`/` 将读者导向 `./guide/quickstart`,`/en/` 则把同一相对目标解析为 `/en/guide/quickstart`。当网站托管在源站的子路径下时,相对目标仍会保留配置的 `DOCS_BASE`。 + +重定向由 `docs/user/index.md` 与 `docs/user/index.zh.md` 的 VitePress frontmatter 维护。对于 locale 首页,[文档网站投影器](../process/2026-07-13-documentation-site-projection.md)只发布这段 frontmatter,因此权威 Markdown 保留中英文语言切换行,且不会渲染第二个首页。投影器测试验证两个 locale 根路由都使用相对于各自 locale 的同一快速开始目标。 + +文档网站不承载产品定位和功能摘要。快速开始页面仍提供指南、开发、参考、搜索和 locale 导航。 + +## 考虑过的替代方案 + +**保留文档 hero 并同步其文案。** 这样会保留一个推广入口页,但也会产生第二套产品叙事,其中的声明和术语可能与产品首页逐渐偏离。 + +**在根路由渲染文档索引。** 索引会重复网站已有的导航,并在读者开始首篇操作指南之前插入一次额外选择。 + +**把快速开始内容复制到每个 locale 根路由。** 这样会让两个公开路由同时维护同一篇教程,并需要另一套同步机制。 + +**使用源站绝对路径作为重定向目标。** `/guide/quickstart` 等路径会忽略 `DOCS_BASE`,当文档网站托管在源站的子路径下时将失效。 + +## 结果 + +进入任一 locale 根路由的读者都会立即到达该 locale 的快速开始教程。文档网站放弃推广型首页,产品首页则继续作为产品定位和功能摘要的唯一归属。稳定的根路由仍是有效入口,快速开始内容仍由单一权威来源维护。 diff --git a/docs/user/index.i18n.yaml b/docs/user/index.i18n.yaml index 670a3c8033..30cbd0583b 100644 --- a/docs/user/index.i18n.yaml +++ b/docs/user/index.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/user/index.md -index.md: bf656e391273b828abe67bc0741f2efe7de957c6 -index.zh.md: 1d4a45a1423604d60ec9fba76431f9b7f0844044 +index.md: 6b7f3f2e3f5a2cc0233ee4785d5495bf6369f025 +index.zh.md: 350185cc98d1cafdac26f48dc377384884eec284 diff --git a/docs/user/index.md b/docs/user/index.md index bf656e3912..6b7f3f2e3f 100644 --- a/docs/user/index.md +++ b/docs/user/index.md @@ -1,23 +1,9 @@ --- -layout: home -hero: - name: DeepSeek Harness - text: Plugin-based Coding Agent - tagline: Built on the Cordis microkernel; everything is a plugin - actions: - - theme: brand - text: Quick start - link: /en/guide/quickstart - - theme: alt - text: Develop plugins - link: /en/develop/basic/ -features: - - title: Microkernel - details: The kernel manages plugin lifecycles, events, and dependencies without containing product-specific capabilities. - - title: Plugin-first - details: Models, tools, sessions, and storage are provided by plugins that work together through events. - - title: Composable - details: Select, replace, or extend capabilities through configuration without modifying the Agent Loop. +layout: false +head: + - - meta + - http-equiv: refresh + content: 0; url=./guide/quickstart --- # DeepSeek Harness diff --git a/docs/user/index.zh.md b/docs/user/index.zh.md index 1d4a45a142..350185cc98 100644 --- a/docs/user/index.zh.md +++ b/docs/user/index.zh.md @@ -1,23 +1,9 @@ --- -layout: home -hero: - name: DeepSeek Harness - text: 插件化 Coding Agent - tagline: 基于 Cordis 微内核,一切皆插件 - actions: - - theme: brand - text: 快速开始 - link: /guide/quickstart - - theme: alt - text: 开发插件 - link: /develop/basic/ -features: - - title: 微内核 - details: 内核只负责插件生命周期、事件通信和依赖管理,不包含具体业务能力。 - - title: 插件化 - details: 模型、工具、会话和存储都由插件提供,并通过事件协作。 - - title: 自由组合 - details: 通过配置选择、替换或扩展能力,不需要修改 Agent Loop。 +layout: false +head: + - - meta + - http-equiv: refresh + content: 0; url=./guide/quickstart --- # DeepSeek Harness diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index a8f339cec7..8cbc56fb7c 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -251,6 +251,19 @@ describe('rewriteMarkdown', () => { }) describe('docsPages locale routes', () => { + it('redirects both locale roots to their locale-relative quick-start page', () => { + const homes = docsPages.filter(page => page.sidebar === null) + expect(homes.map(page => page.route).sort()).toEqual(['en/index.md', 'index.md']) + for (const page of homes) { + const source = readFileSync(resolve(repositoryRoot, page.source), 'utf8') + const projected = projectedPageContent(source, page) + expect(projected).toContain('layout: false') + expect(projected).toContain('http-equiv: refresh') + expect(projected).toContain('content: 0; url=./guide/quickstart') + expect(projected).not.toContain('# DeepSeek Harness') + } + }) + it('publishes every route in both locales and uses every available Chinese counterpart', () => { const byRoute = new Map(docsPages.map(page => [page.route, page])) for (const page of docsPages.filter(page => page.locale === 'root')) { @@ -388,9 +401,9 @@ describe('projectedPageContent', () => { it('omits the source-only body from locale home pages', () => { expect(projectedPageContent( - '---\nlayout: home\nhero:\n name: Harness\n---\n\n# Harness\n\n[English](index.md) | 中文\n', + '---\nlayout: false\nhead:\n - - meta\n - http-equiv: refresh\n content: 0; url=./guide/quickstart\n---\n\n# Harness\n\n[English](index.md) | 中文\n', page(null), - )).toBe('---\nlayout: home\nhero:\n name: Harness\n---\n') + )).toBe('---\nlayout: false\nhead:\n - - meta\n - http-equiv: refresh\n content: 0; url=./guide/quickstart\n---\n') }) it('keeps the full body for ordinary pages', () => { 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 21/56] 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 22/56] 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 23/56] 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 24/56] 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 25/56] 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 c598989d0856316e29c143040e657e53114cca6a Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 14:17:13 +0800 Subject: [PATCH 26/56] refactor(cmdline): trim the command-line seams to existing interfaces The web runtime creates its dev-mode client-hmr row in the root tree after Loader settlement with plain loader.create, deleting the vendored Entry.enableRuntime state machine and dsh-cmdline's enableRow export. Include declares the existing EntryGroup.key tree-carrier marker instead of the EntryConfigResolver protocol (its own path stays literal; nothing used a dynamic path). The launcher recognizes no app row: SIGTERM exits 0 on every surface, every boot watches its user patch layers, and the headless runner exits through ctx.appExit, deleting ctx.headlessIo. Also restores the vendor README rescope entry to the position the rescope-vendor exact-edit anchor requires, fixing the master hygiene regression. --- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 8 +- .../2026-08-06-app-owned-command-line.zh.md | 8 +- .../2026-08-11-cmdline-seam-trim.i18n.yaml | 6 + .../2026-08-11-cmdline-seam-trim.md | 30 +++++ .../2026-08-11-cmdline-seam-trim.zh.md | 30 +++++ apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 6 +- apps/cli/reference/README.zh.md | 6 +- apps/cli/src/profile-boot.ts | 30 ++--- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- .../boot/app-boot/tests/user-patches.spec.ts | 24 +++- packages/boot/cmdline/README.i18n.yaml | 4 +- packages/boot/cmdline/README.md | 2 - packages/boot/cmdline/README.zh.md | 2 - packages/boot/cmdline/src/index.ts | 24 ---- packages/boot/cmdline/tests/cmdline.spec.ts | 69 +---------- packages/bundle/headless/README.i18n.yaml | 4 +- packages/bundle/headless/README.md | 4 +- packages/bundle/headless/README.zh.md | 4 +- packages/bundle/headless/src/index.ts | 31 ++--- .../bundle/headless/tests/headless.spec.ts | 44 +++---- packages/bundle/web-app/cordis.patch.yml | 26 ++-- packages/bundle/web-app/src/index.ts | 38 ++++-- packages/bundle/web-app/tests/web-app.spec.ts | 117 +++++++++++++++--- scripts/gen-cordis-catalog.ts | 1 - vendor/README.md | 5 +- vendor/include/src/index.ts | 22 ++-- vendor/loader/src/config/entry.ts | 33 +---- vendor/loader/src/index.ts | 13 +- 32 files changed, 312 insertions(+), 299 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md create mode 100644 .agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 15abf1380b..d0ceccc0e7 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.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/architecture/2026-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 4a05cac5ed7f44fb55c2d4498bf28a43befdb073 -2026-08-06-app-owned-command-line.zh.md: 86a37f416d17c4615152b29d73f171803f24c4c3 +2026-08-06-app-owned-command-line.md: 88c3fe3daed114f937c65b0afe1dbf4867f0a679 +2026-08-06-app-owned-command-line.zh.md: 1f6db72326312809c6c5a90e9bf26b412c7eddd8 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 4a05cac5ed..88c3fe3dae 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -16,7 +16,7 @@ The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `p The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. -The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. +The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and creates the `client-hmr` row after Loader settlement, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. @@ -24,10 +24,10 @@ Two further consequences. Loader mounts sibling rows concurrently, so one row ca Four framework facts shape the mechanism: -- **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context. +- **A profile's rows arrive inside the root include's `patches` option.** Include declares the `EntryGroup.key` tree-carrier marker (as Group does), so Loader keeps its config — entry and patch lists, including Include's own `path` — literal instead of recursively evaluating nested `!!js` nodes in the Include context; each expression resolves in its target row's fiber. - **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services. - **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. -- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain). Enablement is an in-memory Loader override rather than an options rewrite, so Include reapplication cannot silently disable it. The Web bundle also starts client discovery only after enabling the optional row, ensuring the first browser graph already contains its HMR receiver. +- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so the Web runtime creates its conditional row (`dsh web --dev` and its reload chain) in the root tree after Loader settlement. A root-tree row is outside the include, so user-patch reapplication cannot touch it, and the incremental client-module scan adds it to the roster before any page loads — a browser arrives only after a human reads the URL line. This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. @@ -43,7 +43,7 @@ This leaves dependency ordering in Cordis activation and Loader interpolation, w ## Consequences - An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change. -- The launcher still recognizes the headless runner for one-shot process lifetime and the telemetry row for its environment switch; neither path interprets app arguments. +- The launcher recognizes no app row at all: the telemetry row remains its only composition probe (for the environment switch), SIGTERM exits 0 on every surface, every boot watches its user patch layers, and the one-shot runner exits through `ctx.appExit` like any other app. - `--help` leaves every row that depends on the provider's service pending and requests bounded exit; unrelated rows may activate concurrently before teardown. - An app-owned service has no statically declared provider: a bundle shipping consumer rows without that provider fails at settlement with pending entries naming the service, not at load. - A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index 86a37f416d..1f6db72326 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -16,7 +16,7 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 -已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 +已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 在 Loader 结算后创建 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 @@ -24,10 +24,10 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo 四条框架事实塑造了这套机制: -- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。 +- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 声明了 `EntryGroup.key` 树载体标记(与 Group 相同),因此 Loader 让它的配置——条目与 patch 列表,包括 Include 自己的 `path`——保持字面值,而不是在 Include 上下文中递归求值嵌套的 `!!js` 节点;每个表达式都在其目标行的 fiber 中解析。 - **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。 - **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 -- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路)。启用采用 Loader 的内存覆盖而非改写选项,因此 Include 重新应用配置时不会悄然将其禁用。Web 组合包还会在启用可选行之后才启动客户端发现,确保首份浏览器图中已经包含 HMR 接收端。 +- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此 Web runtime 在 Loader 结算后在根树中创建其条件行(`dsh web --dev` 及其重载链路)。根树的行在 include 之外,用户 patch 的重新应用无法触及它;增量式客户端模块扫描会在任何页面加载之前把它加入名录——浏览器只会在人读到 URL 行之后到来。 这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 @@ -43,7 +43,7 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo ## 后果 - 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 -- 启动器仍会识别 headless runner 以管理一次性进程生命周期,并识别 telemetry 行以应用环境开关;两条路径都不解析应用参数。 +- 启动器完全不识别任何应用行:telemetry 行仍是它唯一的组合探测(用于环境开关),SIGTERM 在所有 surface 上以 0 退出,每次启动都监视用户 patch 层,一次性 runner 像任何应用一样经 `ctx.appExit` 退出。 - `--help` 会让所有依赖提供方服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。 - 应用自有服务没有静态声明的提供方:交付了消费行却缺少对应提供方的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 - 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。 diff --git a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml new file mode 100644 index 0000000000..5780c5196a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.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/architecture/2026-08-11-cmdline-seam-trim.md +2026-08-11-cmdline-seam-trim.md: e9d30c94baed0e0e76c36d7561f50353a4b3eace +2026-08-11-cmdline-seam-trim.zh.md: c4ee26d25b72b4d77d6ec2affbb4647e08c7cb1a diff --git a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md new file mode 100644 index 0000000000..e9d30c94ba --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md @@ -0,0 +1,30 @@ +# Agent Note: Trim the command-line seams to existing interfaces + +Status: implemented + +English | [中文](2026-08-11-cmdline-seam-trim.zh.md) + +## Problem + +The app-owned command line ([note](2026-08-06-app-owned-command-line.md)) shipped with three seams that were wider than their consumers needed: a vendored in-memory row-activation state machine (`Entry.enableRuntime` plus `enableRow` exported from `dsh-cmdline`, a command-line package owning a Loader concept), a vendored `EntryConfigResolver` protocol symbol whose only implementer was Include, and a launcher that still recognized the `headless-runner` row to pick SIGTERM exit codes, gate user-patch watching, and provide a `headlessIo` seam duplicating `ctx.appExit`. + +## Decision + +Express all three with interfaces that already exist: + +- **Conditional dev row.** `dsh-web-app` no longer ships a disabled `client-hmr` row; in development mode its runtime plugin creates the row in the root tree after Loader settlement with plain `loader.create`, guarded for reload idempotence. A root-tree row is outside the include, so user-patch reapplication cannot restore it to disabled — the property the in-memory override existed for. The incremental client-module scan adds it to the roster before any page loads; a browser arrives only after a human reads the URL line, and its `EventSource` reconnects by spec. `Entry.enableRuntime`, its two state fields, and `enableRow` are deleted. +- **Tree-carrier config.** Include declares the existing `EntryGroup.key` marker instead of implementing `EntryConfigResolver`; the Loader hook keeps every tree carrier's config literal. Include's own `path` loses `!!js` support — no configuration ever used it, and the pinning test now asserts the literal tree-carrier contract instead. +- **Launcher app-knowledge.** The launcher recognizes no app row. SIGTERM is a supervisor's ordinary stop request and exits 0 on every surface (SIGINT stays 130); the launcher cannot know whether the app considered its work complete, and the previous 143 depended on naming the headless row. Every boot watches its user patch layers — a one-shot surface exits through bounded shutdown, which disposes the watchers before the loop drains. The headless runner exits through `ctx.appExit` like any other app; its output streams are a package-internal `internals` test seam, and `ctx.headlessIo` is deleted. + +## Alternatives considered + +- **Keeping `enableRuntime` but moving `enableRow` out of `dsh-cmdline`**: relocation fixes the package boundary but keeps the vendored state machine whose semantics (survives reapplication, rollback on failure) must be re-derived at every upstream sync. +- **`entry.update({ disabled: null })`**: mutates the entry's serialized options, so the next include reapplication restores `disabled: true` and unmounts the row mid-session. +- **SIGTERM 143 for one-shot surfaces via an app-registered signal handler**: the launcher's own handler races it for the exit code; winning that race needs a new launcher interface, which is the cost this change removes. + +## Consequences + +- A deployment that supervises `dsh --profile headless` with SIGTERM now observes exit 0 instead of 143; the caller sent the signal and sees no answer on stdout. +- The `--dev` reload row is not covered by the boot activation audit; a creation failure is logged, not fatal. +- One-shot runs mount the config-watch rows they previously skipped, costing a few milliseconds of startup. +- The vendored Loader/Include divergence shrinks by one protocol symbol and one state machine, and `rescope-vendor:check` passes again (the modification log's rescope entry is restored to the position its exact-edit anchor requires). diff --git a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md new file mode 100644 index 0000000000..c4ee26d25b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md @@ -0,0 +1,30 @@ +# Agent Note:把命令行接缝收窄到既有接口 + +Status: implemented + +[English](2026-08-11-cmdline-seam-trim.md) | 中文 + +## 问题 + +应用自有命令行([笔记](2026-08-06-app-owned-command-line.md))交付时带着三条比其消费者所需更宽的接缝:一台 vendored 的内存行激活状态机(`Entry.enableRuntime`,外加从 `dsh-cmdline` 导出的 `enableRow` —— 一个命令行包拥有了 Loader 概念)、一个只有 Include 一个实现者的 vendored `EntryConfigResolver` 协议符号,以及仍然识别 `headless-runner` 行的启动器 —— 用它选择 SIGTERM 退出码、门控用户 patch 监视,并提供与 `ctx.appExit` 重复的 `headlessIo` 接缝。 + +## 决策 + +三者全部改用已经存在的接口表达: + +- **条件 dev 行。** `dsh-web-app` 不再随附禁用的 `client-hmr` 行;开发模式下其 runtime 插件在 Loader 结算后用普通的 `loader.create` 在根树中创建该行,并带重载幂等保护。根树的行在 include 之外,用户 patch 的重新应用无法把它恢复为禁用 —— 这正是内存覆盖机制存在的理由。增量式客户端模块扫描会在任何页面加载之前把它加入名录;浏览器只会在人读到 URL 行之后到来,其 `EventSource` 按规范自动重连。`Entry.enableRuntime`、它的两个状态字段和 `enableRow` 一并删除。 +- **树载体配置。** Include 改为声明已有的 `EntryGroup.key` 标记,不再实现 `EntryConfigResolver`;Loader 钩子让每个树载体的配置保持字面值。Include 自己的 `path` 失去 `!!js` 支持 —— 从未有配置用过它,固定该行为的测试改为断言字面值树载体约定。 +- **启动器的应用知识。** 启动器不再识别任何应用行。SIGTERM 是监督进程的普通停止请求,在所有 surface 上以 0 退出(SIGINT 仍为 130);启动器无从知道应用是否认为工作已完成,而之前的 143 依赖于点名 headless 行。每次启动都监视用户 patch 层 —— 一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器再排空事件循环。headless runner 像任何应用一样经 `ctx.appExit` 退出;其输出流是包内 `internals` 测试接缝,`ctx.headlessIo` 删除。 + +## 考虑过的替代方案 + +- **保留 `enableRuntime` 但把 `enableRow` 移出 `dsh-cmdline`**:搬迁修正了包边界,却保留了 vendored 状态机,其语义(在重新应用后仍生效、失败时回滚)在每次上游同步时都要重新推导。 +- **`entry.update({ disabled: null })`**:改写条目的序列化选项,下一次 include 重新应用会恢复 `disabled: true` 并在会话中途卸载该行。 +- **通过应用注册的信号处理器为一次性 surface 保留 SIGTERM 143**:启动器自己的处理器会与它竞争退出码;要赢得竞争需要新的启动器接口,而这正是本次变更要移除的成本。 + +## 后果 + +- 用 SIGTERM 监督 `dsh --profile headless` 的部署现在观察到退出码 0 而非 143;信号是调用方自己发的,且 stdout 上没有答案。 +- `--dev` 重载行不在启动激活审计的覆盖内;创建失败只记录日志,不致命。 +- 一次性运行会挂载之前跳过的配置监视行,启动多花几毫秒。 +- vendored Loader/Include 偏差减少一个协议符号和一台状态机,`rescope-vendor:check` 重新通过(修改日志的 rescope 条目回到其精确编辑锚点要求的位置)。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index a2cfed084a..83d0763593 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: cef687dc392f97886ea18b162e8f668a44ce2284 -README.zh.md: f6721ec256d404e2b602fb2ed921b41c03633a82 +README.md: 8a38677868f85d4a5b24376a96f0e336c13fa89c +README.zh.md: c0ee1a5fabb8eec6bd34a8a386b2e2409d570e55 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index cef687dc39..8a38677868 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -52,7 +52,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode, which mounts the client-plugin HMR receiver row after Loader settlement; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web @@ -63,9 +63,9 @@ dsh web --help The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. -Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. +Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed. -All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup. +All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Every profile boot watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a one-shot surface exits through its bounded shutdown, which disposes the watchers. New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index f6721ec256..c0ee1a5fab 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -52,7 +52,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式,由其在 Loader 结算后挂载客户端插件 HMR(热模块替换)接收器行;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web @@ -63,9 +63,9 @@ dsh web --help 生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 -进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。 +进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空——`SIGTERM` 是监督进程的普通停止请求,在所有 surface 上以 0 退出,`SIGINT` 报告 130;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。 -所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性运行只在启动时读取这些文件一次。 +所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。每次 profile 启动都监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器。 新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index f3c356f199..32d29bcff2 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -38,7 +38,6 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import { provideCmdline } from '@deepseek-ai/dsh-cmdline' -import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' import { resolveWindowsShellLayer } from './windows-shell.ts' @@ -60,9 +59,6 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ const TELEMETRY_ROW_ID = 'telemetry-otel' -/** The one-shot runner row: its presence means this composition exits by itself. */ -const HEADLESS_ROW_ID = 'headless-runner' - /** The empty root entry list every profile tree patches over. */ const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches: # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any @@ -206,11 +202,6 @@ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { const composed = composeProfile(options.profile, options.patchFiles) - // A one-shot composition ends by itself, which changes what a signal means - // and makes watching the user's patch layer pointless. - const headlessRow = composed.rows.get(HEADLESS_ROW_ID) - const oneShot = headlessRow !== undefined && headlessRow.disabled !== true - const app: { current?: Context } = {} const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) const signalShutdown = new AbortController() @@ -220,7 +211,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } // Signals own teardown throughout the startup window, not only after boot() // settles: an inserted provider can publish before sibling rows finish mounting. - process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) }) + // SIGTERM is a supervisor's ordinary stop request and exits 0 on every + // surface — the launcher does not know whether the app considered its work + // complete; SIGINT is a user interrupt and reports 130. + process.on('SIGTERM', () => { interrupt(0) }) process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() @@ -246,9 +240,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con ...loadOptionalPatches(NAME, homePatchPath()) ?? [], ...composed.overlays, ]) - // One-shot runs exit through the runner; watching would only hold the - // process open after its exit request. - const watchProfilePatch = !oneShot // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { @@ -262,22 +253,15 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con args: options.args, exit: code => void shutdown.shutdown(code), }) - if (oneShot) { - const io: HeadlessIo = { - stdout: process.stdout, - stderr: process.stderr, - exit: (code) => { void shutdown.shutdown(code) }, - } - hostCtx.provide('headlessIo', io) - } }) app.current = ctx // A surface can dispose the whole tree while boot or this post-boot watcher // setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race // from a real HMR error. - if (watchProfilePatch - && !signalShutdown.signal.aborted + // Watching is unconditional: a one-shot surface exits through its bounded + // shutdown, which disposes the watchers before the loop drains. + if (!signalShutdown.signal.aborted && ctx.fiber.state === FiberState.ACTIVE && ctx.get('loader') !== undefined) { try { diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7619aa7e91..ad89062432 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: 36ad950428f02f0147c465b633aff8e9ccd4c002 -config-catalog.zh.md: adfa319e4d753e03993e607d333df46745c20370 +config-catalog.md: f6129274172a5c88af2c1c05bf8b07a73ed4f56e +config-catalog.zh.md: 000ad2c8e6d366649b1f72a45943d826d7ab96c7 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 36ad950428..f612927417 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -579,7 +579,7 @@ export interface Config { } ``` -Source: [`packages/bundle/headless/src/index.ts:29`](../packages/bundle/headless/src/index.ts) +Source: [`packages/bundle/headless/src/index.ts:31`](../packages/bundle/headless/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -2574,7 +2574,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index adfa319e4d..000ad2c8e6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -581,7 +581,7 @@ export interface Config { } ``` -来源:[`packages/bundle/headless/src/index.ts:29`](../packages/bundle/headless/src/index.ts) +来源:[`packages/bundle/headless/src/index.ts:31`](../packages/bundle/headless/src/index.ts) ## `@deepseek-ai/dsh-hooks-claude` @@ -2575,7 +2575,7 @@ export interface Config { export type WebMode = 'production' | 'development' ``` -来源:[`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index da58524e1d..9cd423c31c 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -110,21 +110,33 @@ function entryConfig(ctx: Context, id: string): unknown { } describe('Loader config interpolation', () => { - it("resolves Include's own !!js options", async () => { + it("keeps Include's config literal — a nested row's !!js belongs to that row's fiber", async () => { const dir = tmp() - writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') - writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n') + writeFileSync(join(dir, 'reader.mjs'), [ + 'export const name = "reader"', + 'export function apply(ctx, config) { ctx.provide("observedValue", config.value) }', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '- id: reader\n name: ./reader.mjs\n') const ctx = new Context() await ctx.plugin(Loader) ctx.loader.builtins.include = Include - ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href) + ctx.provide('answer', 42) try { + // The include is a tree carrier: its own config (path, patches) stays + // literal, and the expression nested inside the patched row's config + // resolves against the row's fiber, not the include's. await ctx.loader.create({ name: 'cordis:include', - config: { path: { __jsExpr: "ctx.get('includePath')" } }, + config: { + path: pathToFileURL(join(dir, 'cordis.yml')).href, + patches: [{ id: 'reader', name: './reader.mjs', config: { value: { __jsExpr: "ctx.get('answer')" } } }], + }, }) await ctx.loader.await() - expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true) + const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader') + expect(reader?.options.config).toEqual({ value: { __jsExpr: "ctx.get('answer')" } }) + expect(ctx.get('observedValue')).toBe(42) } finally { await ctx.fiber.dispose() } diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 6a032582cf..9d30c65bb8 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/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/boot/cmdline/README.md -README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96 -README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114 +README.md: 2e8e58b23785fa78bd2663a459817669309a81be +README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 98335e901b..2e8e58b237 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -51,8 +51,6 @@ Every row configured from those values uses ordinary service injection and direc Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset. -`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering. - ### Shared immutable arguments `get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 28ea749943..c04d76905e 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -51,8 +51,6 @@ export function apply(ctx: Context): void { Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。 -`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。 - ### 共享不可变参数 `get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。 diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index fb502cb0e2..ebe8d95aee 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -17,8 +17,6 @@ import type { Command } from 'commander' import type { Context } from '@deepseek-ai/cordis' -// Empty type import carries the Loader Context merge used by enableRow. -import type {} from '@deepseek-ai/cordis-plugin-loader' /** * The invocation's inner arguments: everything after the launcher's own flags, @@ -133,28 +131,6 @@ export function parseCmdline( } } -/** - * Turn on a row this composition ships disabled, because this invocation asked - * for it (`dsh web --dev` and its client-plugin reload chain). - * - * A row cannot be inserted from inside a mounting plugin — the Loader returns a - * prefixed id it then fails to resolve — so a conditional row ships disabled - * and a row mounted beside it enables it after startup resolves the invocation. - * The Loader keeps that activation in memory, separate from serialized options, - * so reapplying the composition cannot restore the invocation's row to disabled. - * @param ctx - plugin context whose Loader tree carries the row. - * @param id - the row id. - * @returns nothing once the row has started or is waiting for its dependencies. - * @throws when the Loader or named row is absent. - */ -export async function enableRow(ctx: Context, id: string): Promise { - const loader = ctx.get('loader') - if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service') - const entry = [...loader.entries()].find(candidate => candidate.options.id === id) - if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`) - await entry.enableRuntime() -} - /** * Whether a thrown value is commander's own control-flow error (help, version, * a parse error, or `program.error`). diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index bc9b63c9aa..941bfe727e 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -14,9 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { afterEach, describe, expect, it } from 'vitest' -import { - enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan, -} from '../src/index.ts' +import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { @@ -175,71 +173,6 @@ describe('parseCmdline', () => { }) }) -describe('enableRow', () => { - it('enables the named Loader row and fails loud when the Loader or row is absent', async () => { - const withoutLoader = new Context() - await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service') - - const ctx = new Context() - let enabled = false - ctx.provide('loader', { - entries: () => [{ - options: { id: 'client-hmr' }, - enableRuntime: async () => { enabled = true }, - }], - } as never) - await enableRow(ctx, 'client-hmr') - expect(enabled).toBe(true) - await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable') - }) - - it('keeps invocation-only activation through config reapplication', async () => { - const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-')) - const observed = { starts: 0, stops: 0 } - ;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed - writeFileSync(join(dir, 'conditional.mjs'), ` -export function apply(ctx) { - globalThis.__runtimeEnableObserved.starts += 1 - ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 }) -} -`) - writeFileSync(join(dir, 'cordis.yml'), [ - '- id: conditional', - ` name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`, - ' disabled: true', - '', - ].join('\n')) - - const ctx = new Context() - await ctx.plugin(Loader) - ctx.loader.builtins.include = Include - await ctx.loader.create({ - name: 'cordis:include', - config: { path: pathToFileURL(join(dir, 'cordis.yml')).href }, - }) - await ctx.loader.await() - const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional') - const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include') - expect(conditional).toBeDefined() - expect(include?.fiber).toBeDefined() - expect(conditional?.options.disabled).toBe(true) - expect(observed).toEqual({ starts: 0, stops: 0 }) - - await enableRow(ctx, 'conditional') - await ctx.loader.await() - expect(conditional?.disabled).toBe(false) - expect(conditional?.options.disabled).toBe(true) - expect(observed).toEqual({ starts: 1, stops: 0 }) - - await include!.fiber!.update(include!.options.config, true) - await ctx.loader.await() - expect(conditional?.disabled).toBe(false) - expect(conditional?.options.disabled).toBe(true) - expect(observed).toEqual({ starts: 1, stops: 0 }) - disposers.push(async () => { await ctx.fiber.dispose() }) - }) -}) - describe('provideCmdline', () => { it('hands the app a snapshot the caller cannot mutate afterwards', () => { const ctx = new Context() diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 4377802ae4..1f64102d8c 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/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/bundle/headless/README.md -README.md: 31a4894dbb191d2244371ca7272339e96e253053 -README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a +README.md: 3d9ca350f5f8891e60cfc57c9ca89ef57d9790d3 +README.zh.md: 1dcba9635b37efebeb0cc1129cc67bc7c01d0d1d diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 31a4894dbb..3d9ca350f5 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin. -After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates. +After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates. ## Model Experience @@ -17,4 +17,4 @@ None; the runner adds nothing to the request prefix. ## Known Limitations and Deferred Work - **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval. -- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the hook. +- **`ctx.appExit` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the exit request. diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 6e8d28f100..1dcba9635b 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -4,7 +4,7 @@ dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 -Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。 +Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.md))请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。 ## 模型体验 @@ -17,4 +17,4 @@ Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/a ## 已知限制与延期工作 - **只提交一个任务**:runner 没有用于交互式后续输入的 surface;它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。 -- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该钩子。 +- **`ctx.appExit` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该退出请求。 diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index d818e4bccd..6a0cbfbbed 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -16,8 +16,10 @@ import type {} from '@deepseek-ai/dsh-agent-default-model' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' -// Empty type import carries the loader Context merge for the settlement await. +// Empty type imports carry the loader Context merge for the settlement await +// and the cmdline Context merge for the appExit host value. import type {} from '@deepseek-ai/cordis-plugin-loader' +import type {} from '@deepseek-ai/dsh-cmdline' /** Stable Cordis plugin name. */ export const name = 'headless-runner' @@ -41,22 +43,18 @@ interface RunOutcome { reason: SessionEvent<'turn/end'>['data']['reason'] | undefined } -/** - * Process-facing effects of one run, injectable for tests. The launcher owns - * bounded tree shutdown and wires `exit()` to it. - */ -export interface HeadlessIo { +/** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */ +interface HeadlessIo { stdout: { write(chunk: string): unknown } stderr: { write(chunk: string): unknown } /** Request process exit with `code` after the tree disposes. */ exit(code: number): void } -declare module '@deepseek-ai/cordis' { - interface Context { - /** Process-facing effects provided before the headless tree mounts. */ - headlessIo?: HeadlessIo - } +/** The process streams the runner writes to; tests substitute captures. */ +export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = { + stdout: process.stdout, + stderr: process.stderr, } /** Aggregate the last assistant text and turn outcome in one owned interval. */ @@ -137,13 +135,16 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { /** * Mount the one-shot direct driver. - * @param ctx - plugin context carrying core services and the launcher-owned IO seam. + * @param ctx - plugin context carrying core services and the launcher-provided exit request. * @param config - validated task config. */ export function apply(ctx: Context, config: Config): void { - const io = ctx.headlessIo - if (io === undefined) { - throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts') + // Read through the global service store, not the property proxy: appExit is + // an optional host value, never an injected dependency. + const exit = ctx.get('appExit') + if (exit === undefined) { + throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts') } + const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit } void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) }) } diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index 788e0a5a10..56d3a15c6f 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -1,6 +1,6 @@ /** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' @@ -8,7 +8,10 @@ import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model' import { createAssistantMessage } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' -import { apply, Config, type HeadlessIo } from '../src/index.ts' +import { apply, Config, internals } from '../src/index.ts' + +const originalInternals = { ...internals } +afterEach(() => { Object.assign(internals, originalInternals) }) interface Script { before?(session: Session): void @@ -93,13 +96,10 @@ async function bench(script: Script): Promise<{ let err = '' const order: string[] = [] ctx.on('session/flush', () => { order.push('flush') }) + internals.stdout = { write: (chunk: string) => { out += chunk; return true } } + internals.stderr = { write: (chunk: string) => { err += chunk; return true } } const exited = new Promise((resolve) => { - const io: HeadlessIo = { - stdout: { write: (chunk: string) => { out += chunk; return true } }, - stderr: { write: (chunk: string) => { err += chunk; return true } }, - exit: (code) => { order.push('exit'); resolve(code) }, - } - ctx.provide('headlessIo', io) + ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) }) }) apply(ctx, { task: 'do the thing' }) return { code: await exited, out, err, order } @@ -181,12 +181,10 @@ describe('headless runner', () => { it('reports a direct Agent creation failure', async () => { const ctx = new Context() let err = '' + internals.stdout = { write: () => true } + internals.stderr = { write: (chunk: string) => { err += chunk; return true } } const exited = new Promise((resolve) => { - ctx.provide('headlessIo', { - stdout: { write: () => true }, - stderr: { write: (chunk: string) => { err += chunk; return true } }, - exit: resolve, - } satisfies HeadlessIo) + ctx.provide('appExit', resolve) }) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never) ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never) @@ -200,12 +198,10 @@ describe('headless runner', () => { it('stringifies a non-Error Agent creation failure', async () => { const ctx = new Context() let err = '' + internals.stdout = { write: () => true } + internals.stderr = { write: (chunk: string) => { err += chunk; return true } } const exited = new Promise((resolve) => { - ctx.provide('headlessIo', { - stdout: { write: () => true }, - stderr: { write: (chunk: string) => { err += chunk; return true } }, - exit: resolve, - } satisfies HeadlessIo) + ctx.provide('appExit', resolve) }) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never) ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never) @@ -224,11 +220,9 @@ describe('headless runner', () => { it('abandons a run when the tree is disposed during Loader settlement', async () => { const ctx = new Context() let exited = false - ctx.provide('headlessIo', { - stdout: { write: () => true }, - stderr: { write: () => true }, - exit: () => { exited = true }, - } satisfies HeadlessIo) + internals.stdout = { write: () => true } + internals.stderr = { write: () => true } + ctx.provide('appExit', () => { exited = true }) const services = ctx.plugin((child: Context) => { child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never) child.provide('sessions', {} as never) @@ -246,9 +240,9 @@ describe('headless runner', () => { await ctx.fiber.dispose() }) - it('fails loud without the launcher-owned headlessIo seam', () => { + it('fails loud without the launcher-provided exit request', () => { const ctx = new Context() - expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo') + expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.appExit') }) it('validates config: the task is required', () => { diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 1b34c11d1b..556e94e8dc 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -8,8 +8,8 @@ # The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an # ordinary Cordis service. Rows configured from flags inject that service, so # Loader resolves their expressions only after it exists. The web runtime then -# provides bind-dependent `webRuntime` values to the trust fence and client -# roster. `dsh --profile web --help` provides neither service, so no server binds. +# provides bind-dependent `webRuntime` values to the trust fence. +# `dsh --profile web --help` provides neither service, so no server binds. # ── surface-specific values the base deliberately omits ───────────────────── @@ -119,25 +119,17 @@ surfaceContext: true trustedHosts: !!js ctx.webStartup.trustedHosts - # The client-plugin reload chain: a dev-only row this bundle ships off, - # which the runtime row turns on before client discovery. It is a row rather - # than a child of web-runtime because its node half is a client-side package, - # which a host-side bundle cannot import. - - id: client-hmr - name: '@deepseek-ai/dsh-client-hmr' - inject: [webStartup] - disabled: true - # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── - # Dual-face: this waits for the runtime row to decide whether HMR belongs - # in the first graph. The node half then scans this tree, composes - # window.__DSH_BOOT__, and serves /plugins//client.js; the browser half - # is the module table the shell kernel constructs before cordis exists - # (adopted as a plugin entry by the kernel, never fetched). + # Dual-face: the node half scans this tree, composes window.__DSH_BOOT__, + # and serves /plugins//client.js; the browser half is the module table + # the shell kernel constructs before cordis exists (adopted as a plugin + # entry by the kernel, never fetched). In development mode the web-runtime + # row creates the client-plugin reload chain (dsh-client-hmr) as a root + # tree row after Loader settlement; the incremental scan adds it to the + # roster before any page loads. - id: modules name: '@deepseek-ai/dsh-client-modules' - inject: [webRuntime] # Owns both ends of the web transport: node half binds the gateway to the # webserver under /api; browser half is the fetch/SSE client. diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 0a8ec7ffbb..fd72ad4e6c 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -16,7 +16,6 @@ import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' -import { enableRow } from '@deepseek-ai/dsh-cmdline' import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -28,7 +27,7 @@ export const name = 'web-app' /** This dsh installation's root, from either this package's source or built entry. */ const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) -const HMR_ROW_ID = 'client-hmr' +const HMR_ROW_NAME = '@deepseek-ai/dsh-client-hmr' /** Runtime service that releases Web rows after bind-dependent values resolve. */ const WEB_RUNTIME_SERVICE = 'webRuntime' @@ -141,19 +140,36 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex /** * Mount the Web runtime: dist serving, surface prompt, bash runtime - * variables, and the URL line. + * variables, the development-mode client-hmr row, and the URL line. * @param ctx - plugin context carrying the httpServer service. * @param config - validated {@link Config}. - * @returns nothing once the invocation's client roster and runtime contributions are registered. */ -export async function apply(ctx: Context, config: Config): Promise { - // Client discovery must start after the optional HMR row has a pending - // fiber. Otherwise its first browser graph omits the reload receiver, which - // cannot use that receiver to discover itself later. - if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID) +export function apply(ctx: Context, config: Config): void { + if (config.mode === 'development') { + // The dev reload chain is mounted as a real tree row so the browser + // roster scan includes its client half; it is a row rather than a child + // of this plugin because its node half is a client-side package, which a + // host-side bundle cannot import. Created in the root tree after Loader + // settlement: row creation must stay out of the mounting transaction, + // and a root-tree row survives user-patch reapplication of the include. + // The incremental roster scan picks it up before any page load — a + // browser arrives only after a human reads the URL line. + const loader = ctx.get('loader') + if (loader === undefined) { + ctx.logger.warn('web-app: development mode without a Loader tree mounts no client-hmr row') + } else { + void loader.await().then(async () => { + // The tree can be disposed while settlement was in flight (early + // SIGTERM); re-check before mutating it. A reload of this fiber must + // not duplicate the row a previous generation created. + if (ctx.get('loader') === undefined) return + const mounted = [...ctx.loader.entries()].some(entry => entry.options.name === HMR_ROW_NAME) + if (!mounted) await ctx.loader.create({ name: HMR_ROW_NAME }) + }).catch((error: unknown) => { ctx.logger.error(error) }) + } + } const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts) - // Release dependent rows only after the optional row has a pending fiber and - // bind-dependent trust has been sampled once. + // Release dependent rows only after bind-dependent trust has been sampled once. ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) if (config.surfaceContext) { diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index f8c5079f17..59286a07ff 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -58,17 +58,20 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: return { server, seat: () => fallback } } -/** Install the optional HMR row the runtime sequences before client discovery. */ +/** A fake Loader capturing the dev-mode row creation the runtime performs after settlement. */ function provideHmrRow(ctx: Context, settle: () => Promise = async () => {}): string[] { - const updates: string[] = [] + const created: string[] = [] + const entries: { options: { name: string } }[] = [] ctx.provide('loader', { - entries: () => [{ - options: { id: 'client-hmr' }, - enableRuntime: async () => { updates.push('client-hmr') }, - }], + entries: () => entries[Symbol.iterator](), + create: (options: { name: string }) => { + created.push(options.name) + entries.push({ options }) + return Promise.resolve(options.name) + }, await: settle, } as never) - return updates + return created } interface BashContribution { @@ -92,13 +95,13 @@ describe('web-app runtime glue', () => { } as never) const enabledRows = provideHmrRow(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) + apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) expect(seat()).toBeDefined() // frontend-static claimed the fallback - expect(enabledRows).toEqual(['client-hmr']) + expect(enabledRows).toEqual(['@deepseek-ai/dsh-client-hmr']) expect(ctx.get('webRuntime')).toEqual({ lanAddresses: ['192.168.1.5'], trustedHosts: ['192.168.1.5', 'lab.internal'], @@ -119,7 +122,7 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() @@ -140,7 +143,7 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] })) + apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() @@ -155,12 +158,94 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() }) + it('creates the client-hmr row exactly once across runtime reloads', async () => { + stageDist() + const ctx = new Context() + ctx.provide('httpServer', fakeHttpServer().server) + const created = provideHmrRow(ctx) + const mount = async (): Promise<() => Promise> => { + const fiber = ctx.plugin((child: Context) => { + apply(child, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] })) + }) + await fiber + await new Promise(resolve => setTimeout(resolve, 0)) + return () => fiber.dispose() + } + const disposeFirst = await mount() + expect(created).toEqual(['@deepseek-ai/dsh-client-hmr']) + await disposeFirst() + // A reload generation must not duplicate the row the previous one created. + const disposeSecond = await mount() + expect(created).toEqual(['@deepseek-ai/dsh-client-hmr']) + await disposeSecond() + await ctx.fiber.dispose() + }) + + it('skips the dev row when the tree is disposed during settlement and logs a creation failure', async () => { + stageDist() + const raced = new Context() + raced.provide('httpServer', fakeHttpServer().server) + let release!: () => void + const settlement = new Promise((resolve) => { release = resolve }) + const created: string[] = [] + const disposeLoader = raced.provide('loader', { + entries: () => [][Symbol.iterator](), + create: (options: { name: string }) => { + created.push(options.name) + return Promise.resolve(options.name) + }, + await: () => settlement, + } as never) + apply(raced, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] })) + disposeLoader() + release() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(created).toEqual([]) + await raced.fiber.dispose() + + const failing = new Context() + failing.provide('httpServer', fakeHttpServer().server) + const failure = new Error('row creation failed') + failing.provide('loader', { + entries: () => [][Symbol.iterator](), + create: () => Promise.reject(failure), + await: () => Promise.resolve(), + } as never) + const errors: unknown[] = [] + failing.logger.error = ((error: unknown) => { errors.push(error) }) as typeof failing.logger.error + apply(failing, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(errors).toEqual([failure]) + await failing.fiber.dispose() + }) + + it('mounts no dev row in production and only warns without a Loader in development', async () => { + stageDist() + const prod = new Context() + prod.provide('httpServer', fakeHttpServer().server) + const created = provideHmrRow(prod) + apply(prod, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(created).toEqual([]) + await prod.fiber.dispose() + + const bare = new Context() + bare.provide('httpServer', fakeHttpServer().server) + const warnings: string[] = [] + bare.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof bare.logger.warn + apply(bare, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] })) + expect(warnings).toEqual(['web-app: development mode without a Loader tree mounts no client-hmr row']) + // Let the vitest invariant host settle before tearing the root down. + await new Promise(resolve => setTimeout(resolve, 0)) + await bare.fiber.dispose() + }) + it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { stageDist() // Settlement path: the line waits for loader.await() so supervisors can @@ -171,7 +256,7 @@ describe('web-app runtime glue', () => { const settlement = new Promise((resolve) => { release = resolve }) provideHmrRow(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() release!() @@ -185,7 +270,7 @@ describe('web-app runtime glue', () => { const failed = new Context() failed.provide('httpServer', fakeHttpServer().server) provideHmrRow(failed, async () => { throw new Error('boot failed') }) - await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() await failed.fiber.dispose() @@ -201,7 +286,7 @@ describe('web-app runtime glue', () => { let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) provideHmrRow(torn, () => tornSettlement) - await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) @@ -217,7 +302,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('httpServer', server) - await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing') diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c0b4afed5c..08f588d320 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -117,7 +117,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', - headlessIo: 'not a service: launcher-provided root accessor value (HeadlessIo | undefined) for the headless bundle runner — packages/bundle/headless/README.md owns this launcher contract', launcherEnvironment: 'not a service: launcher-provided root accessor value (EnvironmentSnapshot | undefined) — packages/util/environment/README.md owns this launcher contract', lsp: 'interface-typed (LspService); implementing class Lsp is not the declared type name — packages/lsp/lsp/README.md owns the API', apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the API', diff --git a/vendor/README.md b/vendor/README.md index 0d889e87fd..b132356bf6 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -44,10 +44,9 @@ Keep this log exhaustive — every divergence from upstream must be listed. 12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. 13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. 14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. -15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. -16. **In-memory Loader entry activation in `loader/src/config/entry.ts`**: an invocation can activate a row shipped with `disabled: true` without mutating its serialized options. The override belongs to the mounted entry object, survives Include config reapplication, respects disabled ancestors, and disappears with the entry. Covered by `packages/boot/cmdline/tests/cmdline.spec.ts` and `apps/web/tests/hmr-live.e2e.ts`. +15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include declares the `EntryGroup.key` tree-carrier marker (as Group does): its config is entry and patch lists, so interpolation keeps it literal and a `!!js` expression inside a nested row's config resolves lazily in that row's own fiber (Include's own `path` therefore stays literal too). Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. +16. **`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. 17. **`@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). -18. **`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. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index c67b591978..5ee20ef43e 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -1,4 +1,4 @@ -import { EntryConfigResolver, EntryTree, interpolate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { EntryGroup, EntryTree, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { Context, Service } from '@deepseek-ai/cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' @@ -174,20 +174,12 @@ export namespace Include { export class Include extends EntryTree { static inject = ['loader'] - /** - * Resolve Include's own options while preserving nested entry expressions. - * @param ctx - the Include plugin context. - * @param config - the raw Include config. - * @returns resolved Include options with `initial` and `patches` untouched. - */ - static [EntryConfigResolver](ctx: Context, config: Include.Config): Include.Config { - const { initial, patches, ...own } = config - return { - ...interpolate(ctx, own), - ...(initial === undefined ? {} : { initial }), - ...(patches === undefined ? {} : { patches }), - } - } + // Tree-carrier marker (the Group plugin declares the same): this config is + // entry and patch lists, so the Loader's `internal/config` interpolation + // keeps it literal — a `!!js` expression inside a nested row's config + // belongs to that row's fiber, resolving lazily in the row's own context. + // Include's own fields (`path`, `enableLogs`) therefore stay literal too. + static readonly [EntryGroup.key] = true public filename: string private type?: string diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index 3fc74177f9..573faad38c 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -5,17 +5,6 @@ import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' import { evaluate } from './utils.ts' -/** Static plugin hook for resolving a container config while preserving nested entry configs. */ -export const EntryConfigResolver = Symbol.for('cordis.loader.entry-config-resolver') - -/** - * Resolve a container's own config while preserving any nested entry configs. - * @param ctx - the container plugin context. - * @param config - the container's raw config. - * @returns the config to validate for this activation. - */ -export type EntryConfigResolver = (ctx: Context, config: any) => any - /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { /** Stable id inside the containing entry tree. */ @@ -73,8 +62,6 @@ export class Entry { _initTask?: Promise _disposing = 0 - private runtimeEnabled = false - private runtimeEnableTask?: Promise constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -101,31 +88,15 @@ export class Entry { private _disabled(options: EntryOptions) { // group is always enabled if (options.group) return false - if (options.disabled && !this.runtimeEnabled) return true + if (options.disabled) return true let entry = this.parent.ctx.fiber.entry while (entry) { - if (entry.options.disabled && !entry.runtimeEnabled) return true + if (entry.options.disabled) return true entry = entry.parent.ctx.fiber.entry } return false } - /** - * Enable this in-memory entry without rewriting its configured `disabled` - * value; the override survives config reapplication for this entry object. - * @returns a promise settling after its initial activation attempt. - */ - enableRuntime(): Promise { - if (this.runtimeEnableTask !== undefined) return this.runtimeEnableTask - this.runtimeEnabled = true - this.runtimeEnableTask = this.refresh().catch((error: unknown) => { - this.runtimeEnabled = false - this.runtimeEnableTask = undefined - throw error - }) - return this.runtimeEnableTask - } - evaluate(expr: string) { return evaluate(this.ctx, expr) } diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 3fe3e57949..781353b983 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -1,12 +1,7 @@ import { Context, FiberState, Inject, Service, type Fiber } from '@deepseek-ai/cordis' import { defineProperty, isNullable, type Dict } from '@deepseek-ai/cosmokit' import { ModuleLoader } from './internal.ts' -import { - Entry, - EntryConfigResolver, - type EntryConfigResolver as ConfigResolver, - type EntryOptions, -} from './config/entry.ts' +import { Entry, type EntryOptions } from './config/entry.ts' import { EntryGroup } from './config/group.ts' import isolate from './config/isolate.ts' import { EntryTree } from './config/tree.ts' @@ -97,10 +92,12 @@ export class Loader extends EntryTree { ctx.on('internal/config', function (this: Fiber, _config, next) { const config = next() if (!this.entry || this.parent.fiber?.entry === this.entry) return config + // Tree carriers (Group, Include) keep their configs literal: their + // entry and patch lists hold other rows' configs, whose `!!js` + // expressions belong to those rows' own fibers. const plugin = this.runtime?.callback as Record | undefined if (plugin?.[EntryGroup.key]) return config - const resolve = plugin?.[EntryConfigResolver] as ConfigResolver | undefined - return resolve ? resolve(this.ctx, config) : interpolate(this.ctx, config) + return interpolate(this.ctx, config) }, { global: true }) ctx.on('internal/update', async function (config, noSave, next) { From fb301ace651871f7caab9c75a736bbe54f1b53ea Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 14:41:27 +0800 Subject: [PATCH 27/56] fix(web-app): defer to a user-configured client-hmr row The whole-tree name scan (entries() recurses into subtrees) already skips creation when any patch layer carries the row, including a disabled one; make that contract explicit in the comment, pin it with a test, and record it in the Agent Note. --- .../2026-08-11-cmdline-seam-trim.i18n.yaml | 4 ++-- .../2026-08-11-cmdline-seam-trim.md | 2 +- .../2026-08-11-cmdline-seam-trim.zh.md | 2 +- packages/bundle/web-app/src/index.ts | 7 ++++-- packages/bundle/web-app/tests/web-app.spec.ts | 22 +++++++++++++++++++ 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml index 5780c5196a..d763257134 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.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/architecture/2026-08-11-cmdline-seam-trim.md -2026-08-11-cmdline-seam-trim.md: e9d30c94baed0e0e76c36d7561f50353a4b3eace -2026-08-11-cmdline-seam-trim.zh.md: c4ee26d25b72b4d77d6ec2affbb4647e08c7cb1a +2026-08-11-cmdline-seam-trim.md: 3fb2f3e0941ad4cf6e2fb7e1afbe6cf31af92f41 +2026-08-11-cmdline-seam-trim.zh.md: 002a76fc57e1d26e15c2619a380874a0eecd435f diff --git a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md index e9d30c94ba..3fb2f3e094 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md +++ b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md @@ -12,7 +12,7 @@ The app-owned command line ([note](2026-08-06-app-owned-command-line.md)) shippe Express all three with interfaces that already exist: -- **Conditional dev row.** `dsh-web-app` no longer ships a disabled `client-hmr` row; in development mode its runtime plugin creates the row in the root tree after Loader settlement with plain `loader.create`, guarded for reload idempotence. A root-tree row is outside the include, so user-patch reapplication cannot restore it to disabled — the property the in-memory override existed for. The incremental client-module scan adds it to the roster before any page loads; a browser arrives only after a human reads the URL line, and its `EventSource` reconnects by spec. `Entry.enableRuntime`, its two state fields, and `enableRow` are deleted. +- **Conditional dev row.** `dsh-web-app` no longer ships a disabled `client-hmr` row; in development mode its runtime plugin creates the row in the root tree after Loader settlement with plain `loader.create`; a whole-tree name scan makes the creation reload-idempotent and defers to a user-configured `dsh-client-hmr` row (even a disabled one). A root-tree row is outside the include, so user-patch reapplication cannot restore it to disabled — the property the in-memory override existed for. The incremental client-module scan adds it to the roster before any page loads; a browser arrives only after a human reads the URL line, and its `EventSource` reconnects by spec. `Entry.enableRuntime`, its two state fields, and `enableRow` are deleted. - **Tree-carrier config.** Include declares the existing `EntryGroup.key` marker instead of implementing `EntryConfigResolver`; the Loader hook keeps every tree carrier's config literal. Include's own `path` loses `!!js` support — no configuration ever used it, and the pinning test now asserts the literal tree-carrier contract instead. - **Launcher app-knowledge.** The launcher recognizes no app row. SIGTERM is a supervisor's ordinary stop request and exits 0 on every surface (SIGINT stays 130); the launcher cannot know whether the app considered its work complete, and the previous 143 depended on naming the headless row. Every boot watches its user patch layers — a one-shot surface exits through bounded shutdown, which disposes the watchers before the loop drains. The headless runner exits through `ctx.appExit` like any other app; its output streams are a package-internal `internals` test seam, and `ctx.headlessIo` is deleted. diff --git a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md index c4ee26d25b..002a76fc57 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md @@ -12,7 +12,7 @@ Status: implemented 三者全部改用已经存在的接口表达: -- **条件 dev 行。** `dsh-web-app` 不再随附禁用的 `client-hmr` 行;开发模式下其 runtime 插件在 Loader 结算后用普通的 `loader.create` 在根树中创建该行,并带重载幂等保护。根树的行在 include 之外,用户 patch 的重新应用无法把它恢复为禁用 —— 这正是内存覆盖机制存在的理由。增量式客户端模块扫描会在任何页面加载之前把它加入名录;浏览器只会在人读到 URL 行之后到来,其 `EventSource` 按规范自动重连。`Entry.enableRuntime`、它的两个状态字段和 `enableRow` 一并删除。 +- **条件 dev 行。** `dsh-web-app` 不再随附禁用的 `client-hmr` 行;开发模式下其 runtime 插件在 Loader 结算后用普通的 `loader.create` 在根树中创建该行;全树名称扫描让创建具备重载幂等性,并让位于用户自行配置的 `dsh-client-hmr` 行(即便该行被禁用)。根树的行在 include 之外,用户 patch 的重新应用无法把它恢复为禁用 —— 这正是内存覆盖机制存在的理由。增量式客户端模块扫描会在任何页面加载之前把它加入名录;浏览器只会在人读到 URL 行之后到来,其 `EventSource` 按规范自动重连。`Entry.enableRuntime`、它的两个状态字段和 `enableRow` 一并删除。 - **树载体配置。** Include 改为声明已有的 `EntryGroup.key` 标记,不再实现 `EntryConfigResolver`;Loader 钩子让每个树载体的配置保持字面值。Include 自己的 `path` 失去 `!!js` 支持 —— 从未有配置用过它,固定该行为的测试改为断言字面值树载体约定。 - **启动器的应用知识。** 启动器不再识别任何应用行。SIGTERM 是监督进程的普通停止请求,在所有 surface 上以 0 退出(SIGINT 仍为 130);启动器无从知道应用是否认为工作已完成,而之前的 143 依赖于点名 headless 行。每次启动都监视用户 patch 层 —— 一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器再排空事件循环。headless runner 像任何应用一样经 `ctx.appExit` 退出;其输出流是包内 `internals` 测试接缝,`ctx.headlessIo` 删除。 diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index fd72ad4e6c..dcac201ba0 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -160,8 +160,11 @@ export function apply(ctx: Context, config: Config): void { } else { void loader.await().then(async () => { // The tree can be disposed while settlement was in flight (early - // SIGTERM); re-check before mutating it. A reload of this fiber must - // not duplicate the row a previous generation created. + // SIGTERM); re-check before mutating it. The name scan spans every + // tree (entries() recurses into subtrees), so a row the user + // configured in a patch layer — enabled, reconfigured, or + // deliberately disabled — wins over this default, and a reload of + // this fiber never duplicates the row a previous generation created. if (ctx.get('loader') === undefined) return const mounted = [...ctx.loader.entries()].some(entry => entry.options.name === HMR_ROW_NAME) if (!mounted) await ctx.loader.create({ name: HMR_ROW_NAME }) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 59286a07ff..272ae54258 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -187,6 +187,28 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) + it('defers to a user-configured client-hmr row anywhere in the tree', async () => { + stageDist() + const ctx = new Context() + ctx.provide('httpServer', fakeHttpServer().server) + const created: string[] = [] + // The user's own row — possibly patched into an include subtree and even + // disabled there — already carries the name; the runtime must not create + // a second one beside it. + ctx.provide('loader', { + entries: () => [{ options: { id: 'my-hmr', name: '@deepseek-ai/dsh-client-hmr', disabled: true } }][Symbol.iterator](), + create: (options: { name: string }) => { + created.push(options.name) + return Promise.resolve(options.name) + }, + await: () => Promise.resolve(), + } as never) + apply(ctx, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(created).toEqual([]) + await ctx.fiber.dispose() + }) + it('skips the dev row when the tree is disposed during settlement and logs a creation failure', async () => { stageDist() const raced = new Context() From dc42e6b8220fbd10c28a4ddb03cf34c4a4142b82 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 11 Aug 2026 18:00:56 +0800 Subject: [PATCH 28/56] feat(web): toast anchoring and model-selection rejection banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toast sits 120px from the viewport top and centers over its anchor — the composer card, so it centers on the chat column rather than the window; a rejected model selection (e.g. picking a text-only model while the session holds images) announces through the same banner while the in-menu strip with Retry stays the catalog-load surface. The attachment rail consumes every wheel tick with a vertical component: a diagonal pan keeps its horizontal intent and nothing scrolls the conversation behind the composer. --- ...web-attachment-display-alignment.i18n.yaml | 4 +- ...-08-11-web-attachment-display-alignment.md | 2 +- ...-11-web-attachment-display-alignment.zh.md | 2 +- .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- .../ui-attachment/src/AttachmentRail.tsx | 21 +++--- .../tests/attachment-rail.spec.tsx | 10 ++- .../src/client/skeleton/InputBar.tsx | 3 + .../ui-model/src/client/ModelSelect.tsx | 65 +++++++++++++++---- .../ui-model/tests/model-select.spec.tsx | 32 +++++++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../client/ui-primitives/src/Toast.module.css | 2 +- packages/client/ui-primitives/src/Toast.tsx | 24 ++++++- .../client/ui-primitives/tests/toast.spec.tsx | 19 +++++- 17 files changed, 158 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml index 3386b9fec0..24bd9f6081 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.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-11-web-attachment-display-alignment.md -2026-08-11-web-attachment-display-alignment.md: 0c41b337d90293525c31afd60aa16bbc3f7cf16c -2026-08-11-web-attachment-display-alignment.zh.md: c2ca9cf546cdb62f3674867ae420e8a4f071f98f +2026-08-11-web-attachment-display-alignment.md: 18c732c078ef6efd17e5c83708ba6e68ac663ce6 +2026-08-11-web-attachment-display-alignment.zh.md: 8a4222c31cd1fa2ad75ca314513c057fdb780d07 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md index 0c41b337d9..18c732c078 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.md @@ -16,7 +16,7 @@ All of this UI also lived inside `dsh-client-ui-conversation` — the rail inlin Attachment display lives in a new zero-cordis atoms package, `@deepseek-ai/dsh-client-ui-attachment` (`packages/client/ui-attachment`), patterned on `dsh-client-ui-primitives`: `AttachmentRail` (64px/16px-radius thumbnails, single-click `onOpen`, inside-the-card remove control revealed on hover or focus and permanent under `pointer: coarse`, hidden scrollbar with circular edge arrows recomputed from scroll geometry, vertical-wheel horizontal pan clamped to 60px/tick, end-reveal on growth), `MessageImage`/`ImageGallery` (single-click preview), and `ImageLightbox`. Strings arrive as label props; `ui-conversation` bridges its `conversation` dictionary through `src/client/image-labels.ts` and keeps the machine wiring (draft ids, preview state, intake callbacks). The cross-package import is sanctioned exactly because the package is an atoms library, not a client plugin: plugin-to-plugin component imports stay forbidden, and the composer's rail is composer-owned rendering, not a slot. -Both overlays body-portal: the lightbox opened from a chat message sits under transformed ancestors that would trap `position: fixed` in their own box (the backdrop covered only the chat column), so `ImageLightbox` and `Toast` render through `createPortal(document.body)` and cover the viewport from every opener. The transient banner is a `ui-primitives` `Toast` atom (top-center, `role="alert"`, three-second hold then one-second fade, `onDone` unmount, keyed per show so identical repeated messages re-announce). `InputBar` routes both intake rejections (`addImages`'s returned reason) and `promptError` through it, replacing the inline strips; the machine-notice strip is untouched. DeepSeek Chat's source (a local reference copy) provided the target behaviors: its `ImageThumbnailInInput` (64px cards, opacity-transition delete), `ScrollArrows` (sentinel-driven paging), and `useToast` usage. +Both overlays body-portal: the lightbox opened from a chat message sits under transformed ancestors that would trap `position: fixed` in their own box (the backdrop covered only the chat column), so `ImageLightbox` and `Toast` render through `createPortal(document.body)` and cover the viewport from every opener. The transient banner is a `ui-primitives` `Toast` atom (120px from the viewport top, horizontally centered over its optional anchor — the composer card, so it sits over the chat column — `role="alert"`, `pointer-events: none`, three-second hold then one-second fade, `onDone` unmount, keyed per show so identical repeated messages re-announce). `InputBar` routes both intake rejections (`addImages`'s returned reason) and `promptError` through it, replacing the inline strips, and `ModelSelect` routes rejected model selections through the same atom while its in-menu strip with Retry stays the catalog-load surface; the machine-notice strip is untouched. DeepSeek Chat's source (a local reference copy) provided the target behaviors: its `ImageThumbnailInInput` (64px cards, opacity-transition delete), `ScrollArrows` (sentinel-driven paging), and `useToast` usage. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md index c2ca9cf546..8a4222c31c 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-web-attachment-display-alignment.zh.md @@ -16,7 +16,7 @@ Web 输入框的图片界面缺乏基本可用性(用户反馈,issue #2248 附件展示落位到新的零 cordis 原子组件包 `@deepseek-ai/dsh-client-ui-attachment`(`packages/client/ui-attachment`),模式照 `dsh-client-ui-primitives`:`AttachmentRail`(64px、16px 圆角缩略图,单击 `onOpen`,卡片内部的删除按钮悬停或聚焦显示、`pointer: coarse` 下常显,隐藏滚动条配两端圆形箭头并依滚动几何重算,纵向滚轮转横向平移且单次钳制 60px,新增条目滚到栏尾),`MessageImage`/`ImageGallery`(单击预览),以及 `ImageLightbox`。文案经 label props 传入;`ui-conversation` 通过 `src/client/image-labels.ts` 桥接 `conversation` 词典,并保留状态机接线(草稿 id、预览状态、接收回调)。跨包 import 之所以是被允许的路径,正因为它是原子组件库而非 client 插件:插件之间仍禁止互相 import 组件,且附件栏是输入框自有的渲染,不是插槽。 -两个浮层都 portal 到 body:从聊天消息打开的灯箱位于带 transform 的祖先之下,`position: fixed` 会被困在祖先的盒子里(遮罩只盖住聊天列),因此 `ImageLightbox` 与 `Toast` 经 `createPortal(document.body)` 渲染,从任何打开位置都覆盖整个视口。短时横幅是 `ui-primitives` 的 `Toast` 原子(顶部居中,`role="alert"`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。 +两个浮层都 portal 到 body:从聊天消息打开的灯箱位于带 transform 的祖先之下,`position: fixed` 会被困在祖先的盒子里(遮罩只盖住聊天列),因此 `ImageLightbox` 与 `Toast` 经 `createPortal(document.body)` 渲染,从任何打开位置都覆盖整个视口。短时横幅是 `ui-primitives` 的 `Toast` 原子(距视口顶部 120px,水平中心跟随可选锚点——composer 卡片,因此横幅在聊天列上居中——`role="alert"`、`pointer-events: none`,停留三秒再一秒淡出,`onDone` 卸载,按展示序号作 key 使相同文案重新播报)。`InputBar` 把接收拒绝(`addImages` 返回的原因)和 `promptError` 都改走 toast,替换内联红条,`ModelSelect` 的模型选择被拒也走同一原子,其菜单内带 Retry 的错误条仍是目录加载的呈现面;状态机 notice 条不受影响。DeepSeek Chat 源码(本地参考副本)提供了目标行为:其 `ImageThumbnailInInput`(64px 卡片、透明度过渡的删除钮)、`ScrollArrows`(哨兵驱动的翻页)与 `useToast` 用法。 ## 备选方案 diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 03417393c9..faea84bfdb 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-attachment/README.md -README.md: 7e67a0064f5611ac814a9d489db0d4cc471309e1 -README.zh.md: bf44f6a42db0e1a2c06e1f42133918150b123c2e +README.md: 9fab9c23b958606030b1e87fcbfa45130c980947 +README.zh.md: 668dba11154538f52a9a87692020868c1b8a63d5 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 7e67a0064f..9fab9c23b9 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -6,7 +6,7 @@ Pure React attachment atoms (zero cordis): the composer draft-image rail (`Attac ## Attachment rail -`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). A vertical wheel pans the rail horizontally through a non-passive listener that consumes the event — the same tick never also scrolls the conversation — with LINE/PAGE deltas normalized to pixels and per-tick travel clamped to 60px, while trackpad horizontal pans keep native scrolling. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist. +`AttachmentRail` renders pending draft images as fixed 64px thumbnails (16px radius) in one horizontally scrolling row whose scrollbar stays hidden. Overflow is announced by circular edge arrows instead: each pages one viewport (minus one card of context, floored at 200px) with smooth scrolling (instant under `prefers-reduced-motion: reduce`), and arrow visibility is recomputed from scroll geometry on scroll, item-count changes, and rail size changes (a ResizeObserver on the rail element, so sidebar and panel resizes count, not only window resizes). The rail scrolls horizontally only: a non-passive listener consumes every wheel tick with a vertical component — nothing scrolls the conversation behind the composer — converting a pure vertical wheel to a horizontal step (LINE/PAGE deltas normalized to pixels, per-tick travel clamped to 60px) and keeping a diagonal pan's horizontal intent, while purely horizontal pans stay native. A newly added item is revealed at the rail's end; removal keeps the scroll position, and a rail that mounts over an already-populated draft keeps its start position. Each thumbnail opens its original through `onOpen` on a single click, and its remove control sits inside the card's top-right corner, hidden until the card is hovered or the control keyboard-focused; coarse-pointer (touch) surfaces show it permanently because they have no hover. The owner decides mounting and renders the rail only while items exist. ## Message images and the lightbox diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index bf44f6a42d..668dba1115 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -6,7 +6,7 @@ ## 附件栏 -`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动(`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算(rail 元素上的 ResizeObserver,因此侧栏、面板的宽度变化也计入,不只是窗口尺寸变化)。纵向滚轮经非 passive 监听器转为横向平移并独占消费该事件,同一次滚动不会同时滚动会话记录;LINE/PAGE 单位的增量先归一化为像素,单次行程钳制在 60px 内,触控板的横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。 +`AttachmentRail` 将待发送草稿图片渲染为固定 64px(16px 圆角)的缩略图横排,滚动条始终隐藏,溢出改由两端的圆形箭头提示:每次翻页滚动一个视口宽度(减去一张卡片作为上下文,下限 200px)并平滑滚动(`prefers-reduced-motion: reduce` 下瞬时完成),箭头的显隐在滚动、条目数量变化和栏自身尺寸变化时依据滚动几何重算(rail 元素上的 ResizeObserver,因此侧栏、面板的宽度变化也计入,不只是窗口尺寸变化)。附件栏只允许横向滚动:非 passive 监听器消费所有带纵向分量的滚轮事件——不会滚动输入框背后的会话记录——纯纵向滚轮转为横向步进(LINE/PAGE 单位先归一化为像素,单次行程钳制在 60px 内),对角平移保留其横向分量,纯横向平移保持原生滚动。新增条目会滚动到栏尾展示,删除则保持原位,带着已有草稿重新挂载的栏保持起始位置。每张缩略图单击经 `onOpen` 打开原图,删除按钮位于卡片内部右上角,悬停卡片或键盘聚焦时才显示;粗指针(触屏)设备没有悬停,因此常显。是否挂载由持有方决定,仅在有条目时渲染。 ## 消息图片与灯箱 diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx index 22ecf39147..65e2df109b 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.tsx +++ b/packages/client/ui-attachment/src/AttachmentRail.tsx @@ -109,20 +109,25 @@ export function AttachmentRail({ items, labels, on observer.observe(el) disconnect = () => { observer.disconnect() } } - // A vertical wheel pans the rail horizontally and is consumed: without - // preventDefault the same tick would also scroll the conversation behind - // the composer. React's root wheel listener is passive, so the exclusive - // conversion needs this manually attached non-passive listener. LINE and - // PAGE deltas (Firefox notch wheels) are normalized to pixels before the - // per-tick clamp that keeps a fast wheel followable. + // The rail scrolls horizontally ONLY: any wheel tick with a vertical + // component is consumed — without preventDefault it would also scroll the + // conversation behind the composer, and React's root wheel listener is + // passive, so the exclusion needs this manually attached non-passive + // listener. A diagonal trackpad pan keeps its horizontal intent; a pure + // vertical wheel converts to a horizontal step, with LINE and PAGE deltas + // (Firefox notch wheels) normalized to pixels before the per-tick clamp + // that keeps a fast wheel followable. A purely horizontal pan stays + // native. const onWheel = (event: globalThis.WheelEvent): void => { - if (event.deltaX !== 0 || event.deltaY === 0) return + if (event.deltaY === 0) return const scale = event.deltaMode === WheelEvent.DOM_DELTA_LINE ? WHEEL_LINE_PX : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? el.clientWidth : 1 event.preventDefault() el.scrollBy({ - left: Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60), + left: event.deltaX !== 0 + ? event.deltaX * scale + : Math.sign(event.deltaY) * Math.min(Math.abs(event.deltaY) * scale, 60), behavior: 'auto', }) } diff --git a/packages/client/ui-attachment/tests/attachment-rail.spec.tsx b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx index 210b8829c9..a373464b61 100644 --- a/packages/client/ui-attachment/tests/attachment-rail.spec.tsx +++ b/packages/client/ui-attachment/tests/attachment-rail.spec.tsx @@ -128,10 +128,14 @@ describe('AttachmentRail', () => { expect(scrollBy).toHaveBeenCalledWith({ left: 32, behavior: 'auto' }) fireEvent.wheel(rail, { deltaY: -1, deltaMode: WheelEvent.DOM_DELTA_PAGE }) expect(scrollBy).toHaveBeenCalledWith({ left: -60, behavior: 'auto' }) - // A trackpad pan (deltaX) and a zero-delta wheel keep native behavior. - expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(true) + // A diagonal pan is consumed too — nothing vertical may escape the rail — + // and keeps its horizontal intent. + expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 30 })).toBe(false) + expect(scrollBy).toHaveBeenCalledWith({ left: 12, behavior: 'auto' }) + // A purely horizontal pan and a zero-delta wheel keep native behavior. + expect(fireEvent.wheel(rail, { deltaX: 12, deltaY: 0 })).toBe(true) fireEvent.wheel(rail, { deltaY: 0 }) - expect(scrollBy).toHaveBeenCalledTimes(5) + expect(scrollBy).toHaveBeenCalledTimes(6) }) it('pages instantly under a reduced-motion preference, smoothly otherwise', () => { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index b6b60b9053..901bdc4682 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -89,6 +89,7 @@ export function InputBar({ if (promptError !== null) showToast(`${promptError.error.message} (${promptError.error.code})`) }, [promptError, showToast]) const inputRef = useRef(null) + const cardRef = useRef(null) const dragDepthRef = useRef(0) const scrollRef = useRef(null) const mirrorRef = useRef(null) @@ -577,6 +578,7 @@ export function InputBar({ key={toast.seq} text={toast.text} icon={} + anchor={cardRef.current} onDone={dismissToast} /> )} @@ -591,6 +593,7 @@ export function InputBar({ pointerdown stops here so the Menu's outside-close cannot race the click's reopen (close-then-open flickers the chip's open echo). */}
('root') + // The in-menu error strip serves catalog loads (its Retry re-runs the + // load); a rejected SELECTION announces through the transient toast + // instead, so the strip renders only while the latest failure-capable + // action was a load. + const lastActionRef = useRef<'load' | 'select'>('load') + const [toast, setToast] = useState<{ seq: number; text: string } | null>(null) + const toastSeq = useRef(0) const rootRef = useRef(null) const triggerRef = useRef(null) const itemRefs = useRef<(HTMLButtonElement | null)[]>([]) @@ -92,9 +102,17 @@ export function ModelSelect( ], [reasoning, t]) const busy = state.status === 'selecting' + const reload = (): void => { + lastActionRef.current = 'load' + load() + } + // Mount-time load resolves the trigger label; every open refreshes. useEffect(() => { - if (available) load() + if (available) { + lastActionRef.current = 'load' + load() + } }, [available, load]) useEffect(() => { @@ -111,7 +129,7 @@ export function ModelSelect( const show = (): void => { setPane('root') setOpen(true) - load() + reload() } const close = (restoreFocus = false): void => { @@ -148,14 +166,25 @@ export function ModelSelect( close() } + const settleSelection = (accepted: boolean): void => { + if (accepted) { + if (rootRef.current !== null) close(true) + return + } + const message = directory.getSnapshot().error + if (message !== null) { + toastSeq.current += 1 + setToast({ seq: toastSeq.current, text: t('error.action', { message }) }) + } + } + const choose = (selection: ModelSelection): void => { if (state.current?.provider === selection.provider && state.current.model === selection.model) { close(true) return } - void select(selection).then((accepted) => { - if (accepted && rootRef.current !== null) close(true) - }) + lastActionRef.current = 'select' + void select(selection).then(settleSelection) } const chooseEffort = (effort: string | undefined): void => { @@ -169,9 +198,8 @@ export function ModelSelect( model: state.current.model, ...effort === undefined ? {} : { reasoningEffort: effort }, } - void select(selection).then((accepted) => { - if (accepted && rootRef.current !== null) close(true) - }) + lastActionRef.current = 'select' + void select(selection).then(settleSelection) } const modelLabel = currentChoice?.model.name ?? t('trigger.fallback') @@ -243,16 +271,16 @@ export function ModelSelect( {state.status === 'loading' && (
{t('status.loading')}
)} - {state.error !== null && ( + {state.error !== null && lastActionRef.current === 'load' && (
{t('error.action', { message: state.error })} - +
)} {state.failures.map(failure => (
{t('warning.groupLoad', { name: failure.name, message: failure.message })} - +
))}
@@ -299,10 +327,10 @@ export function ModelSelect( {pane === 'effort' && ( <> - {state.error !== null && ( + {state.error !== null && lastActionRef.current === 'load' && (
{t('error.action', { message: state.error })} - +
)} {effortChoices.length === 0 @@ -333,6 +361,15 @@ export function ModelSelect( )}
)} + {toast !== null && ( + } + anchor={rootRef.current?.closest('[data-composer-card]') ?? null} + onDone={() => { setToast(null) }} + /> + )}
) } diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx index 1b1e1ef17c..4f6a37d151 100644 --- a/packages/client/ui-model/tests/model-select.spec.tsx +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -135,6 +135,38 @@ describe('ModelSelect reasoning effort', () => { expect(screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })).toBeTruthy() }) + it('announces a rejected selection as a transient toast and keeps the in-menu strip for loads', async () => { + const groups = [{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [ + { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }, + { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' }, + ], + }] + const directory = createSnapshotStore(state({ groups })) + const select = vi.fn(async () => { + directory.set(state({ groups, status: 'error', error: 'model-unavailable: session already contains images' })) + return false + }) + render() + + fireEvent.click(screen.getByRole('button', { name: /选择模型|当前/ })) + fireEvent.click(screen.getByRole('menuitem', { name: /模型/ })) + fireEvent.click(screen.getByRole('menuitemradio', { name: /DeepSeek-V4-Pro/ })) + const toast = await screen.findByRole('alert') + expect(toast.textContent).toContain('模型操作失败:model-unavailable: session already contains images') + // The selection failure does not render the in-menu load strip (no Retry). + expect(screen.queryByRole('button', { name: '重试' })).toBeNull() + }) + it('renders no Agent-bound control for an addressed subagent session', () => { const load = vi.fn() render( void }) { useEffect(() => { const timer = setTimeout(onDone, HOLD_MS + FADE_MS) return () => { clearTimeout(timer) } }, [onDone]) + // Anchor-centered placement re-measures on window resizes; the banner lives + // four seconds, so sub-window layout drift within that span stays out of + // scope. + const [left, setLeft] = useState(null) + useLayoutEffect(() => { + if (anchor == null) return + const measure = (): void => { + const rect = anchor.getBoundingClientRect() + setLeft(rect.left + rect.width / 2) + } + measure() + window.addEventListener('resize', measure) + return () => { window.removeEventListener('resize', measure) } + }, [anchor]) return createPortal( -
+
{icon !== undefined && {icon}} {text}
, diff --git a/packages/client/ui-primitives/tests/toast.spec.tsx b/packages/client/ui-primitives/tests/toast.spec.tsx index 5fdd4d2f48..1e2bb35d51 100644 --- a/packages/client/ui-primitives/tests/toast.spec.tsx +++ b/packages/client/ui-primitives/tests/toast.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from '@testing-library/react' +import { cleanup, fireEvent, render } from '@testing-library/react' import { Toast } from '../src/Toast.tsx' afterEach(cleanup) @@ -24,6 +24,23 @@ describe('Toast', () => { } }) + it('centers over its anchor and re-measures on window resize', () => { + vi.useFakeTimers() + try { + const anchor = document.createElement('div') + document.body.appendChild(anchor) + anchor.getBoundingClientRect = () => ({ left: 100, width: 400 }) as DOMRect + const view = render() + expect(view.getByRole('alert').style.left).toBe('300px') + anchor.getBoundingClientRect = () => ({ left: 200, width: 400 }) as DOMRect + fireEvent(window, new Event('resize')) + expect(view.getByRole('alert').style.left).toBe('400px') + anchor.remove() + } finally { + vi.useRealTimers() + } + }) + it('renders without an icon and cancels its timer on unmount', () => { vi.useFakeTimers() try { From 341051603f99714b529d4650b5664bf66c5dbdd1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 15:39:03 +0800 Subject: [PATCH 29/56] refactor(web): remove --dev; mount the reload chain unconditionally The client-hmr row joins the web bundle as an ordinary always-on roster row: without a rebuild watcher rewriting client bundles it polls unchanged files and stays idle. This deletes the --dev flag, the web runtime's mode config, the mode-forked prompt contract, the DSH_WEB_MODE bash variable, and the post-settlement row-creation machinery the conditional row required. dsh web + pnpm run dev:web remains the development loop. --- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 10 +- ...26-07-23-client-plugin-loading-model.zh.md | 10 +- ...026-08-06-app-owned-command-line.i18n.yaml | 4 +- .../2026-08-06-app-owned-command-line.md | 5 +- .../2026-08-06-app-owned-command-line.zh.md | 5 +- .../2026-08-11-cmdline-seam-trim.i18n.yaml | 4 +- .../2026-08-11-cmdline-seam-trim.md | 7 +- .../2026-08-11-cmdline-seam-trim.zh.md | 7 +- ...2026-07-28-web-gui-feedback-loop.i18n.yaml | 4 +- .../2026-07-28-web-gui-feedback-loop.md | 10 +- .../2026-07-28-web-gui-feedback-loop.zh.md | 10 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 4 +- apps/cli/reference/README.zh.md | 4 +- apps/web/tests/hmr-live.e2e.ts | 6 +- apps/web/tests/replay-round-trip.e2e.ts | 4 +- apps/web/tests/scaffold.ts | 2 +- apps/web/tests/smoke-real.e2e.ts | 8 +- .../system-prompt.expected.md | 2 +- .../development-prompt.expected.md | 1 - .../web-surface-prompt.expected.md | 1 + apps/web/vite.config.ts | 2 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 13 +- docs/config-catalog.zh.md | 13 +- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 6 +- packages/bundle/web-app/README.zh.md | 6 +- packages/bundle/web-app/cordis.patch.yml | 23 +-- packages/bundle/web-app/src/index.ts | 61 ++----- packages/bundle/web-app/src/startup.ts | 8 +- packages/bundle/web-app/tests/startup.spec.ts | 6 +- packages/bundle/web-app/tests/web-app.spec.ts | 156 +++--------------- packages/client/hmr/README.i18n.yaml | 4 +- packages/client/hmr/README.md | 2 +- packages/client/hmr/README.zh.md | 2 +- packages/client/hmr/src/index.ts | 4 +- scripts/dev-web.ts | 2 +- 42 files changed, 136 insertions(+), 304 deletions(-) delete mode 100644 apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md create mode 100644 apps/web/tests/snapshots/web-runtime-context/web-surface-prompt.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index cebb8b554a..2c4184a667 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.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/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: 860186294059facf17d1eba38423092acf7a4a7d -2026-07-23-client-plugin-loading-model.zh.md: 68f2e70253485d4e215c981d3b338d5046390247 +2026-07-23-client-plugin-loading-model.md: 3347bdac95eb8e06be3e7c20d24319103f738149 +2026-07-23-client-plugin-loading-model.zh.md: d52409e167b536162a8efb9a9ecc3092f6e5d1d2 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 8601862940..3347bdac95 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -31,7 +31,7 @@ What makes a package a plugin? One rule: **a package is a plugin package once it - **Plain packages** are the absolute base the module system itself needs, plus libraries not yet converted to DI: the react family, cordis, `@deepseek-ai/dsh-client-modules` (the module system itself — it can never be a plugin, because modules precede all modules), the web shell kernel, and — for now — ui-slots, web-react, ui-primitives. Plain packages are shell-bundled, seeded into the module table, and invisible to the host graph. - **Plugin packages** are everything else. Each one carries a `dsh.client` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-question, and ui-trajectory. -The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster and the `--dev` switch. +The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster. To add a plugin package: declare `dsh.client`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands. @@ -66,7 +66,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the **Host side — compose the graph.** -1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). +1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)). 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber. 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is served as a script resource at `/plugins//client.js?rev=…`, with its source map at the same path plus `.map`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself). @@ -84,7 +84,7 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a ### Hot reload: one driver plugin, self-watched bundles -Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither. +Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row. How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev. @@ -117,12 +117,12 @@ The support boundary, stated honestly. Reload is coarse by design: fresh fiber, | `dsh-client-runtime` | session object layer + slots service + store engine | plugin, declares `immediately` | keeps shrinking toward a pure session object layer | | `dsh-client-ui-theme` | theme tokens/service | plugin, declares `immediately`, plus the `./styles/*` source channel | Theme Registry (separate ruling) | | `dsh-client-i18n` | I18nService | plugin, declares `immediately` | per-deployment locale composition | -| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately`; dev graphs only | rollback; reconnect handshake | +| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately` | rollback; reconnect handshake | | ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI features | plugins, on-demand | conversation domain split; trajectory real implementation | ## Consequences -One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster and the `--dev` switch. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook. +One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook. Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch appears at the settled sweep, not at graph validation; the three not-yet-promoted libraries keep their static-import exports until their DI conversions land; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 68f2e70253..d52409e167 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -31,7 +31,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac - **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库:react 家族、cordis、`@deepseek-ai/dsh-client-modules`(模块系统本身——它永远不可能是插件,因为模块先于一切模块)、web 壳内核,以及——暂时——ui-slots、web-react、ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。 - **插件包**是其余一切。每个都携带 `dsh.client` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括:connection、runtime、ui-theme、i18n、hmr(仅进 dev 图)、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。 -manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册与 `--dev` 开关。 +manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册。 新增一个插件包:声明 `dsh.client`,经共享预设产出 `./client` bundle,把包名加进负责组合的 app 的名册。除此之外无需任何交接。 @@ -66,7 +66,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点 **host 侧——组合这张图。** -1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。 +1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。 2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都作为脚本资源供给:`/plugins//client.js?rev=…`,对应 sourcemap 位于同一路径加 `.map`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。 @@ -84,7 +84,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点 ### 热重载:一个驱动插件,自行监视的 bundle -热重载是否启用是一项组合决策:dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;prod 组合不挂载,两者皆无。 +热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不得暴露它的组合可在 patch 层禁用该行。 重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态,文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。 @@ -117,12 +117,12 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点 | `dsh-client-runtime` | 会话对象层 + slots 服务 + store 引擎 | 插件,声明 `immediately` | 持续缩向纯会话对象层 | | `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`,外加 `./styles/*` 源码通道 | Theme Registry(另行裁定) | | `dsh-client-i18n` | I18nService | 插件,声明 `immediately` | 按部署组合语言包 | -| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately`;仅进 dev 图 | 回滚;重连握手 | +| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately` | 回滚;重连握手 | | ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI 功能 | 插件,按需到达 | conversation 域拆分;trajectory 真实现 | ## Consequences -wire 两侧跑着同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。插件包只有一种形态,纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册与 `--dev` 开关。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。浏览器原生脚本装载使插件网络资源、生成 bundle 与 TypeScript/TSX 源码保持标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。 +wire 两侧跑着同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。插件包只有一种形态,纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。浏览器原生脚本装载使插件网络资源、生成 bundle 与 TypeScript/TSX 源码保持标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面;每个 bundle 多出一份 sourcemap 产物,外部脚本失败也只能给出粗粒度的 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index d0ceccc0e7..5ee9d06358 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.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/architecture/2026-08-06-app-owned-command-line.md -2026-08-06-app-owned-command-line.md: 88c3fe3daed114f937c65b0afe1dbf4867f0a679 -2026-08-06-app-owned-command-line.zh.md: 1f6db72326312809c6c5a90e9bf26b412c7eddd8 +2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920 +2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md index 88c3fe3dae..2480775f65 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -16,18 +16,17 @@ The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `p The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. -The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and creates the `client-hmr` row after Loader settlement, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. +The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family, and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. ## Why Loader owns the ordering -Four framework facts shape the mechanism: +Three framework facts shape the mechanism: - **A profile's rows arrive inside the root include's `patches` option.** Include declares the `EntryGroup.key` tree-carrier marker (as Group does), so Loader keeps its config — entry and patch lists, including Include's own `path` — literal instead of recursively evaluating nested `!!js` nodes in the Include context; each expression resolves in its target row's fiber. - **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services. - **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. -- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so the Web runtime creates its conditional row (`dsh web --dev` and its reload chain) in the root tree after Loader settlement. A root-tree row is outside the include, so user-patch reapplication cannot touch it, and the incremental client-module scan adds it to the roster before any page loads — a browser arrives only after a human reads the URL line. This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md index 1f6db72326..d754c125d5 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -16,18 +16,17 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 -已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 在 Loader 结算后创建 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 +已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族,`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 ## 为什么由 Loader 持有顺序 -四条框架事实塑造了这套机制: +三条框架事实塑造了这套机制: - **profile 的各行位于根 include 的 `patches` 选项内部。** Include 声明了 `EntryGroup.key` 树载体标记(与 Group 相同),因此 Loader 让它的配置——条目与 patch 列表,包括 Include 自己的 `path`——保持字面值,而不是在 Include 上下文中递归求值嵌套的 `!!js` 节点;每个表达式都在其目标行的 fiber 中解析。 - **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。 - **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 -- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此 Web runtime 在 Loader 结算后在根树中创建其条件行(`dsh web --dev` 及其重载链路)。根树的行在 include 之外,用户 patch 的重新应用无法触及它;增量式客户端模块扫描会在任何页面加载之前把它加入名录——浏览器只会在人读到 URL 行之后到来。 这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 diff --git a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml index d763257134..de65010ba6 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.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/architecture/2026-08-11-cmdline-seam-trim.md -2026-08-11-cmdline-seam-trim.md: 3fb2f3e0941ad4cf6e2fb7e1afbe6cf31af92f41 -2026-08-11-cmdline-seam-trim.zh.md: 002a76fc57e1d26e15c2619a380874a0eecd435f +2026-08-11-cmdline-seam-trim.md: d7908d2c80552f13500ccd36c59a249f1374cbb1 +2026-08-11-cmdline-seam-trim.zh.md: 275a32b584afdbcc77d2356775f35c74786445ab diff --git a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md index 3fb2f3e094..d7908d2c80 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md +++ b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md @@ -6,13 +6,13 @@ English | [中文](2026-08-11-cmdline-seam-trim.zh.md) ## Problem -The app-owned command line ([note](2026-08-06-app-owned-command-line.md)) shipped with three seams that were wider than their consumers needed: a vendored in-memory row-activation state machine (`Entry.enableRuntime` plus `enableRow` exported from `dsh-cmdline`, a command-line package owning a Loader concept), a vendored `EntryConfigResolver` protocol symbol whose only implementer was Include, and a launcher that still recognized the `headless-runner` row to pick SIGTERM exit codes, gate user-patch watching, and provide a `headlessIo` seam duplicating `ctx.appExit`. +The app-owned command line ([note](2026-08-06-app-owned-command-line.md)) shipped with three seams that were wider than their consumers needed: a vendored in-memory row-activation state machine (`Entry.enableRuntime` plus `enableRow` exported from `dsh-cmdline`, a command-line package owning a Loader concept) whose only purpose was the `--dev` conditional reload row, a vendored `EntryConfigResolver` protocol symbol whose only implementer was Include, and a launcher that still recognized the `headless-runner` row to pick SIGTERM exit codes, gate user-patch watching, and provide a `headlessIo` seam duplicating `ctx.appExit`. ## Decision Express all three with interfaces that already exist: -- **Conditional dev row.** `dsh-web-app` no longer ships a disabled `client-hmr` row; in development mode its runtime plugin creates the row in the root tree after Loader settlement with plain `loader.create`; a whole-tree name scan makes the creation reload-idempotent and defers to a user-configured `dsh-client-hmr` row (even a disabled one). A root-tree row is outside the include, so user-patch reapplication cannot restore it to disabled — the property the in-memory override existed for. The incremental client-module scan adds it to the roster before any page loads; a browser arrives only after a human reads the URL line, and its `EventSource` reconnects by spec. `Entry.enableRuntime`, its two state fields, and `enableRow` are deleted. +- **No conditional dev row.** The reload chain stops being conditional: `dsh-web-app` mounts the `client-hmr` row unconditionally and `--dev` is deleted, along with the web runtime's `mode` config, the mode-forked prompt contract, and the `DSH_WEB_MODE` bash variable. Without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the chain polls unchanged files and stays idle, so the always-on row costs one stat-poll interval and an SSE route. `Entry.enableRuntime`, its two state fields, and `enableRow` are deleted with nothing replacing them. - **Tree-carrier config.** Include declares the existing `EntryGroup.key` marker instead of implementing `EntryConfigResolver`; the Loader hook keeps every tree carrier's config literal. Include's own `path` loses `!!js` support — no configuration ever used it, and the pinning test now asserts the literal tree-carrier contract instead. - **Launcher app-knowledge.** The launcher recognizes no app row. SIGTERM is a supervisor's ordinary stop request and exits 0 on every surface (SIGINT stays 130); the launcher cannot know whether the app considered its work complete, and the previous 143 depended on naming the headless row. Every boot watches its user patch layers — a one-shot surface exits through bounded shutdown, which disposes the watchers before the loop drains. The headless runner exits through `ctx.appExit` like any other app; its output streams are a package-internal `internals` test seam, and `ctx.headlessIo` is deleted. @@ -21,10 +21,11 @@ Express all three with interfaces that already exist: - **Keeping `enableRuntime` but moving `enableRow` out of `dsh-cmdline`**: relocation fixes the package boundary but keeps the vendored state machine whose semantics (survives reapplication, rollback on failure) must be re-derived at every upstream sync. - **`entry.update({ disabled: null })`**: mutates the entry's serialized options, so the next include reapplication restores `disabled: true` and unmounts the row mid-session. - **SIGTERM 143 for one-shot surfaces via an app-registered signal handler**: the launcher's own handler races it for the exit code; winning that race needs a new launcher interface, which is the cost this change removes. +- **Keeping `--dev` with the row created at runtime**: an interim state of this change; it still needed a mode fork in the prompt contract, a `DSH_WEB_MODE` variable, and creation-versus-user-row arbitration, all to avoid an idle poll whose cost is negligible. ## Consequences - A deployment that supervises `dsh --profile headless` with SIGTERM now observes exit 0 instead of 143; the caller sent the signal and sees no answer on stdout. -- The `--dev` reload row is not covered by the boot activation audit; a creation failure is logged, not fatal. +- The reload chain runs in every `dsh web` process; a deployment that must not expose `/plugins/events` disables the `client-hmr` row in its patch layer. - One-shot runs mount the config-watch rows they previously skipped, costing a few milliseconds of startup. - The vendored Loader/Include divergence shrinks by one protocol symbol and one state machine, and `rescope-vendor:check` passes again (the modification log's rescope entry is restored to the position its exact-edit anchor requires). diff --git a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md index 002a76fc57..275a32b584 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -应用自有命令行([笔记](2026-08-06-app-owned-command-line.md))交付时带着三条比其消费者所需更宽的接缝:一台 vendored 的内存行激活状态机(`Entry.enableRuntime`,外加从 `dsh-cmdline` 导出的 `enableRow` —— 一个命令行包拥有了 Loader 概念)、一个只有 Include 一个实现者的 vendored `EntryConfigResolver` 协议符号,以及仍然识别 `headless-runner` 行的启动器 —— 用它选择 SIGTERM 退出码、门控用户 patch 监视,并提供与 `ctx.appExit` 重复的 `headlessIo` 接缝。 +应用自有命令行([笔记](2026-08-06-app-owned-command-line.md))交付时带着三条比其消费者所需更宽的接缝:一台 vendored 的内存行激活状态机(`Entry.enableRuntime`,外加从 `dsh-cmdline` 导出的 `enableRow` —— 一个命令行包拥有了 Loader 概念),其唯一用途是 `--dev` 条件重载行、一个只有 Include 一个实现者的 vendored `EntryConfigResolver` 协议符号,以及仍然识别 `headless-runner` 行的启动器 —— 用它选择 SIGTERM 退出码、门控用户 patch 监视,并提供与 `ctx.appExit` 重复的 `headlessIo` 接缝。 ## 决策 三者全部改用已经存在的接口表达: -- **条件 dev 行。** `dsh-web-app` 不再随附禁用的 `client-hmr` 行;开发模式下其 runtime 插件在 Loader 结算后用普通的 `loader.create` 在根树中创建该行;全树名称扫描让创建具备重载幂等性,并让位于用户自行配置的 `dsh-client-hmr` 行(即便该行被禁用)。根树的行在 include 之外,用户 patch 的重新应用无法把它恢复为禁用 —— 这正是内存覆盖机制存在的理由。增量式客户端模块扫描会在任何页面加载之前把它加入名录;浏览器只会在人读到 URL 行之后到来,其 `EventSource` 按规范自动重连。`Entry.enableRuntime`、它的两个状态字段和 `enableRow` 一并删除。 +- **不再有条件 dev 行。** 重载链不再是条件性的:`dsh-web-app` 无条件挂载 `client-hmr` 行,`--dev` 连同 web runtime 的 `mode` 配置、按模式分叉的提示词约定和 `DSH_WEB_MODE` bash 变量一并删除。没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,链路轮询到的文件从不变化、保持空闲,因此常开的行只花费一个 stat 轮询间隔和一条 SSE 路由。`Entry.enableRuntime`、它的两个状态字段和 `enableRow` 删除后无任何替代物。 - **树载体配置。** Include 改为声明已有的 `EntryGroup.key` 标记,不再实现 `EntryConfigResolver`;Loader 钩子让每个树载体的配置保持字面值。Include 自己的 `path` 失去 `!!js` 支持 —— 从未有配置用过它,固定该行为的测试改为断言字面值树载体约定。 - **启动器的应用知识。** 启动器不再识别任何应用行。SIGTERM 是监督进程的普通停止请求,在所有 surface 上以 0 退出(SIGINT 仍为 130);启动器无从知道应用是否认为工作已完成,而之前的 143 依赖于点名 headless 行。每次启动都监视用户 patch 层 —— 一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器再排空事件循环。headless runner 像任何应用一样经 `ctx.appExit` 退出;其输出流是包内 `internals` 测试接缝,`ctx.headlessIo` 删除。 @@ -21,10 +21,11 @@ Status: implemented - **保留 `enableRuntime` 但把 `enableRow` 移出 `dsh-cmdline`**:搬迁修正了包边界,却保留了 vendored 状态机,其语义(在重新应用后仍生效、失败时回滚)在每次上游同步时都要重新推导。 - **`entry.update({ disabled: null })`**:改写条目的序列化选项,下一次 include 重新应用会恢复 `disabled: true` 并在会话中途卸载该行。 - **通过应用注册的信号处理器为一次性 surface 保留 SIGTERM 143**:启动器自己的处理器会与它竞争退出码;要赢得竞争需要新的启动器接口,而这正是本次变更要移除的成本。 +- **保留 `--dev`、改为运行时创建该行**:本次变更的中间形态;它仍需要提示词约定里的模式分叉、`DSH_WEB_MODE` 变量,以及创建与用户自有行之间的仲裁,而这一切只为省下一个成本可忽略的空闲轮询。 ## 后果 - 用 SIGTERM 监督 `dsh --profile headless` 的部署现在观察到退出码 0 而非 143;信号是调用方自己发的,且 stdout 上没有答案。 -- `--dev` 重载行不在启动激活审计的覆盖内;创建失败只记录日志,不致命。 +- 重载链在每个 `dsh web` 进程中运行;不得暴露 `/plugins/events` 的部署应在其 patch 层禁用 `client-hmr` 行。 - 一次性运行会挂载之前跳过的配置监视行,启动多花几毫秒。 - vendored Loader/Include 偏差减少一个协议符号和一台状态机,`rescope-vendor:check` 重新通过(修改日志的 rescope 条目回到其精确编辑锚点要求的位置)。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml index c119ecf984..0d06bc62eb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.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/bug-fix/2026-07-28-web-gui-feedback-loop.md -2026-07-28-web-gui-feedback-loop.md: aa488e1df087722c072d98c67d47cdf63a42f6b8 -2026-07-28-web-gui-feedback-loop.zh.md: 9b6954092b737920ed18bc412037e917512e40dc +2026-07-28-web-gui-feedback-loop.md: fa7fcee80dc91ad7ec4a9a994927daa2cc293baa +2026-07-28-web-gui-feedback-loop.zh.md: ea83441efa83ad0c93e8cb1f0daf71ecfeabb69e diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md index aa488e1df0..fa7fcee80d 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md @@ -12,9 +12,9 @@ The [incident post-mortem](../../../../docs/postmortem/0003-web-agent-gui-feedba ## Decision -The ordinary `dsh web` composition mounts the Web bundle's `web-runtime` plugin, which publishes one canonical loopback URL and its actual runtime mode as both model-visible orientation and managed shell facts. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` and `DSH_WEB_MODE=production|development` carry the same facts into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. A complete-prompt profile sets the row's `surfaceContext` to false and receives neither the prompt section nor the managed variables; the Web launcher uses the same setting to suppress its source-checkout prompt section. +The ordinary `dsh web` composition mounts the Web bundle's `web-runtime` plugin, which publishes one canonical loopback URL as both model-visible orientation and a managed shell fact. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` carries the same fact into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. A complete-prompt profile sets the row's `surfaceContext` to false and receives neither the prompt section nor the managed variable; the Web launcher uses the same setting to suppress its source-checkout prompt section. -The mode-specific prompt makes the agent, rather than the user, own the hidden startup contract. Production mode defines acceptance as rebuilding the affected artifacts and refreshing the existing URL. Development mode states that `dsh web --dev` activates only the HMR receiver: automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuild plus refresh. An agent in production mode explains both commands when a user requests no-refresh updates; it does not launch a replacement GUI unless asked. +The prompt makes the agent, rather than the user, own the hidden startup contract. The client-plugin HMR receiver is always mounted, but automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuilding the affected artifacts and refreshing the existing URL. The agent does not launch a replacement GUI unless asked. The `apps/web` development script and Vite configuration reject serve mode before opening a port. Their diagnostics identify `apps/web` as a build-only shell, explain that only `dsh web` injects `window.__DSH_BOOT__`, and name the production and HMR entry paths. Vite build mode remains unchanged. @@ -22,7 +22,7 @@ No server restart or replacement is required merely because static artifacts cha ## Verification -The keyless fresh-round-trip browser scenario boots the shipped production Web composition, drives a real replayed session, snapshots the URL/mode-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` and `$DSH_WEB_MODE` match the actual bound runtime. The real CLI smoke launches `dsh web --dev` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web --dev`, changes an initial production-roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement. +The keyless fresh-round-trip browser scenario boots the shipped Web composition, drives a real replayed session, snapshots the URL-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` matches the actual bound runtime. The real CLI smoke launches `dsh web` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web`, changes an initial roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement. ## Alternatives considered @@ -30,10 +30,10 @@ The keyless fresh-round-trip browser scenario boots the shipped production Web c **Remove the `apps/web` development script without guarding Vite.** Rejected because `npx vite`, the exact incident command, bypasses package scripts. Serve mode itself must fail. -**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and plugin HMR has a separate explicit `dsh web --dev` composition. +**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and client-plugin reload is owned by the always-mounted HMR chain plus the `pnpm run dev:web` watcher. **Send DOM, route, or screenshots with each request.** Deferred to a separate logged-input design. Stable URL identity closes this feedback loop without claiming browser state the host does not receive. ## Consequences -Ordinary Web prompts gain a dynamic URL-and-mode paragraph, so provider prefix reuse now varies by bound port and mode. Their Bash processes gain two non-secret managed environment variables. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL/mode contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one. Profiles that disable `surfaceContext` also give up this feedback-loop guidance and shell context. +Ordinary Web prompts gain a dynamic URL paragraph, so provider prefix reuse now varies by bound port. Their Bash processes gain one non-secret managed environment variable. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one. Profiles that disable `surfaceContext` also give up this feedback-loop guidance and shell context. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md index 9b6954092b..ea83441efa 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md @@ -12,9 +12,9 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道 ## 决策 -常规 `dsh web` 组合会挂载 Web 组合包的 `web-runtime` 插件,由它发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI,并给出 URL;`DSH_WEB_URL` 和 `DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。拥有完整提示词的 profile 会把该配置行的 `surfaceContext` 设为 false,并且不会收到该提示词段和这些受管变量中的任何一个;Web 启动器也会使用同一项设置来抑制其源码 checkout 提示词段。 +常规 `dsh web` 组合会挂载 Web 组合包的 `web-runtime` 插件,由它发布一个规范的回环 URL,同时将其作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI,并给出 URL;`DSH_WEB_URL` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。拥有完整提示词的 profile 会把该配置行的 `surfaceContext` 设为 false,并且不会收到该提示词段和该受管变量;Web 启动器也会使用同一项设置来抑制其源码 checkout 提示词段。 -按模式区分的提示词让 agent 而非用户负责隐藏的启动约定。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明,`dsh web --dev` 只会启用 HMR(热模块替换)接收端:客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程,agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。 +提示词让 agent 而非用户负责隐藏的启动约定。HMR(热模块替换)接收端始终挂载,但客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程,agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建受影响的产物并刷新现有 URL。除非用户要求,agent 不会启动替代 GUI。 `apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR 入口路径。Vite 构建模式保持不变。 @@ -22,7 +22,7 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道 ## 验证 -无密钥的 fresh-round-trip 浏览器场景会启动已交付的生产 Web 组合,驱动真实的回放会话,对包含 URL 和模式的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 和 `$DSH_WEB_MODE` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web --dev` 并捕获模型提供方请求,从而固定完整的双命令开发约定。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;浏览器 HMR 场景会启动 `dsh web --dev`,修改生产初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。 +无密钥的 fresh-round-trip 浏览器场景会启动已交付的 Web 组合,驱动真实的回放会话,对包含 URL 的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web` 并捕获模型提供方请求,从而固定完整的双命令开发约定。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;浏览器 HMR 场景会启动 `dsh web`,修改初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。 ## 考虑过的替代方案 @@ -30,10 +30,10 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道 **删除 `apps/web` 开发脚本,但不为 Vite 添加防护。** 不予采纳,因为事故中实际使用的命令 `npx vite` 会绕过包脚本。服务模式本身必须失败。 -**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而插件 HMR 已有独立且显式的 `dsh web --dev` 组合。 +**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而客户端插件重载由始终挂载的 HMR 链路加 `pnpm run dev:web` watcher 负责。 **每次请求都发送 DOM、路由或截图。** 推迟到另行设计的已记录输入机制。稳定的 URL 身份足以闭合本次反馈循环,同时不会声称宿主掌握其未接收的浏览器状态。 ## 影响 -常规 Web 提示词会增加一个动态 URL 和模式段落,因此模型提供方的前缀复用会随绑定端口和模式变化。相应的 Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL/模式约定会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。禁用 `surfaceContext` 的 profile 也会放弃这项反馈闭环指引与 shell 上下文。 +常规 Web 提示词会增加一个动态 URL 段落,因此模型提供方的前缀复用会随绑定端口变化。相应的 Bash 进程会增加一个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL 约定会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。禁用 `surfaceContext` 的 profile 也会放弃这项反馈闭环指引与 shell 上下文。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 83d0763593..e5bc192550 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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 apps/cli/reference/README.md -README.md: 8a38677868f85d4a5b24376a96f0e336c13fa89c -README.zh.md: c0ee1a5fabb8eec6bd34a8a386b2e2409d570e55 +README.md: 46ea3c241d6775ce90a89c7be58901375a0634a3 +README.zh.md: f020f46260d6b04b87a4918a671ca6bcbed251d9 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 8a38677868..46ea3c241d 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -24,7 +24,7 @@ The shipped apps own these command lines: | Profile | Arguments | |---|---| -| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` | +| `web` | `--host`, `--port`, repeatable `--trusted-host` | | `headless` | the task text, as the positional argument | A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. @@ -52,7 +52,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode, which mounts the client-plugin HMR receiver row after Loader settlement; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, and repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities). The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index c0ee1a5fab..f020f46260 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -24,7 +24,7 @@ | Profile | 参数 | |---|---| -| `web` | `--host`、`--port`、`--dev`、可重复的 `--trusted-host` | +| `web` | `--host`、`--port`、可重复的 `--trusted-host` | | `headless` | 任务文本,作为位置参数 | 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 @@ -52,7 +52,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式,由其在 Loader 结算后挂载客户端插件 HMR(热模块替换)接收器行;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。 ```sh dsh web diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 57a3867187..fc12fae810 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -1,4 +1,4 @@ -/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */ +/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */ import { existsSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -92,14 +92,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT)) await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') host = subprocessCtx.subprocess.spawn(spawnSpec( - [process.execPath, binPath, 'web', '--dev', '--port', '0'], + [process.execPath, binPath, 'web', '--port', '0'], world, { DEEPSEEK_API_KEY: 'keyless-hmr-no-call', DSH_HOME: join(world, '.dsh'), }, )) - const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev') + const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web') browser = await chromium.launch() const page = await browser.newPage() const pageErrors: string[] = [] diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 0bb0bc8176..f55cb0e7b5 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -101,14 +101,14 @@ describe('web e2e: fresh round trip through the real assembly', () => { callId: CallId('web-url-probe'), name: 'bash', arguments: { - command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"', + command: 'printf \'%s\\n\' "$DSH_WEB_URL"', description: 'Print current Web runtime', }, agent, }) expect(result.isError).toBe(false) expect(result.content.filter(block => block.type === 'text').map(block => block.text).join('')) - .toBe(`${scaffold.baseUrl}\nproduction\n`) + .toBe(`${scaffold.baseUrl}\n`) }) it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 772bd4ae91..0f13282b49 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -416,7 +416,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { return new Promise((resolveReady, reject) => { @@ -187,7 +187,7 @@ describe('dsh web keyless CLI smoke', () => { } }) - it('routes --dev runtime context and workspace instructions through the real CLI request', async () => { + it('routes web runtime context and workspace instructions through the real CLI request', async () => { requireDist() const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-')) mkdirSync(join(workspace, '.git')) @@ -226,7 +226,7 @@ describe('dsh web keyless CLI smoke', () => { const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href const child = spawn( process.execPath, - ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'], { cwd: workspace, env: { @@ -261,7 +261,7 @@ describe('dsh web keyless CLI smoke', () => { const workspaceMessage = captured.messages?.find(message => message.role === 'user' && message.content?.includes('web-workspace-context-probe')) const systemMessage = captured.messages?.find(message => message.role === 'system') - const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd() + const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd() .replace('{{webUrl}}', baseUrl) expect(systemMessage?.content).toContain(expectedWebSection) expect(workspaceMessage).toMatchInlineSnapshot(` diff --git a/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md b/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md index e2113428e1..651695a5f6 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md @@ -2,6 +2,6 @@ You are an AI agent powered by the DeepSeek Harness SDK. The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. -You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL. +You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL. You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. diff --git a/apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md b/apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md deleted file mode 100644 index 58157d4437..0000000000 --- a/apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md +++ /dev/null @@ -1 +0,0 @@ -You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL. diff --git a/apps/web/tests/snapshots/web-runtime-context/web-surface-prompt.expected.md b/apps/web/tests/snapshots/web-runtime-context/web-surface-prompt.expected.md new file mode 100644 index 0000000000..327b34dd71 --- /dev/null +++ b/apps/web/tests/snapshots/web-runtime-context/web-surface-prompt.expected.md @@ -0,0 +1 @@ +You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL. diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 66076c1171..08e12b2e97 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' + 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. ' - + 'For client-plugin HMR, run `pnpm dsh web --dev` together with `pnpm run dev:web`.' + + 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.' /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */ function rejectStandaloneServe(): Plugin { diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index a958569942..be48f9ef42 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.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/api-gateway.md -api-gateway.md: 3065f3f10861965b327a4412049878dc1ba7faec -api-gateway.zh.md: aa9b726c33fd9f51fc0b2d2ed95c4c9658662796 +api-gateway.md: c60793532d621585fc9878b6197497b45015856b +api-gateway.zh.md: daf46f6ad208f4cb959028ff59d98d9de7a072d6 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 3065f3f108..c60793532d 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -141,7 +141,7 @@ SRC solves only dispatch for a Host process running from source. The Client does The repository `dsh` script completes the Host, Client, and Web build before starting the source Host. Web development runs that command and the Client plugin watcher in separate terminals: ```sh -pnpm dsh web --dev +pnpm dsh web pnpm run dev:web ``` diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index aa9b726c33..daf46f6ad2 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -141,7 +141,7 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho 仓库的 `dsh` 脚本会先完成 Host、Client 与 Web 构建,再启动源码 Host。Web 开发需要在两个终端中分别运行该命令和 Client plugin watcher: ```sh -pnpm dsh web --dev +pnpm dsh web pnpm run dev:web ``` diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ad89062432..a6dcbdac7f 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: f6129274172a5c88af2c1c05bf8b07a73ed4f56e -config-catalog.zh.md: 000ad2c8e6d366649b1f72a45943d826d7ab96c7 +config-catalog.md: 10d8e6cbe1ab1729166860680ef0e9f4dd72cb91 +config-catalog.zh.md: fb1c620ef30c85e887335c5664db43e35f408416 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f612927417..10d8e6cbe1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -371,7 +371,7 @@ export interface Config { } ``` -Source: [`packages/client/hmr/src/index.ts:29`](../packages/client/hmr/src/index.ts) +Source: [`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts) ## `@deepseek-ai/dsh-code-runtime-worker` @@ -2555,26 +2555,21 @@ Requires: `httpServer` ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { - /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ - mode: WebMode /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * non-interactive layer can turn it off when its user is not in the GUI, so the + * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive + * layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean /** Explicit `--trusted-host` authorities from this invocation. */ trustedHosts: string[] } - -/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ -export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:38`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 000ad2c8e6..fb1c620ef3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -373,7 +373,7 @@ export interface Config { } ``` -来源:[`packages/client/hmr/src/index.ts:29`](../packages/client/hmr/src/index.ts) +来源:[`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts) ## `@deepseek-ai/dsh-code-runtime-worker` @@ -2556,26 +2556,21 @@ export interface WebServiceConfig { ```ts config-catalog /** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { - /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ - mode: WebMode /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * non-interactive layer can turn it off when its user is not in the GUI, so the + * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive + * layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean /** Explicit `--trusted-host` authorities from this invocation. */ trustedHosts: string[] } - -/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ -export type WebMode = 'production' | 'development' ``` -来源:[`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:38`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 9040315855..0b8d53173c 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/bundle/web-app/README.md -README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd -README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68 +README.md: 06856a47cd8ccc2c6ee5a53c40928b1bd2933cc7 +README.zh.md: 8befc7c7404ea1b082842f122769967fff32df2f diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index b6fa225f5e..06856a47cd 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience @@ -10,7 +10,7 @@ The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides ove #### What the model sees -When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered. +When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered. #### Token effect @@ -18,7 +18,7 @@ One source line and one prompt paragraph per session plus two managed-environmen #### KV Cache effect -The prompt section sits near the system prompt's head and is stable for the life of the process (port and mode are boot facts), so it does not invalidate the cache across turns. +The prompt section sits near the system prompt's head and is stable for the life of the process (the port is a boot fact), so it does not invalidate the cache across turns. ## Known Limitations and Deferred Work diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 68af959719..8befc7c740 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、`--dev`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 @@ -10,7 +10,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 #### 模型看到的内容 -当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。 +当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。 #### Token 影响 @@ -18,7 +18,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 #### KV Cache 影响 -该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口与模式是启动期事实),因此不会使跨轮次缓存失效。 +该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口是启动期事实),因此不会使跨轮次缓存失效。 ## 已知限制与延期工作 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 556e94e8dc..8d84a9bfb4 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -105,29 +105,32 @@ # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the # frontend-static fallback owner, registers the web-surface prompt - # section and bash runtime variables, and prints the URL line. The webStartup - # provider supplies invocation-only values; after the server binds, this row - # samples LAN trust once and provides `webRuntime`. A complete agent-preset - # persona suppresses the prompt section for that agent while retaining - # these host-owned shell variables. + # section and the bash runtime variable, and prints the URL line. The + # webStartup provider supplies invocation-only values; after the server + # binds, this row samples LAN trust once and provides `webRuntime`. A + # complete agent-preset persona suppresses the prompt section for that + # agent while retaining the host-owned shell variable. - id: web-runtime name: '@deepseek-ai/dsh-web-app' inject: [webStartup] config: - mode: !!js ctx.webStartup.mode printUrl: true surfaceContext: true trustedHosts: !!js ctx.webStartup.trustedHosts + # The client-plugin reload chain, always mounted: it is idle until a + # rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It + # is a row rather than a child of web-runtime because its node half is a + # client-side package, which a host-side bundle cannot import. + - id: client-hmr + name: '@deepseek-ai/dsh-client-hmr' + # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── # Dual-face: the node half scans this tree, composes window.__DSH_BOOT__, # and serves /plugins//client.js; the browser half is the module table # the shell kernel constructs before cordis exists (adopted as a plugin - # entry by the kernel, never fetched). In development mode the web-runtime - # row creates the client-plugin reload chain (dsh-client-hmr) as a root - # tree row after Loader settlement; the incremental scan adds it to the - # roster before any page loads. + # entry by the kernel, never fetched). - id: modules name: '@deepseek-ai/dsh-client-modules' diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index dcac201ba0..dfa032213a 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -5,7 +5,7 @@ * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the * harness-source and web-surface prompt sections, the bash-visible web runtime - * variables, and the URL line. App command-line values arrive through the + * variable, and the URL line. App command-line values arrive through the * `webStartup` service expressions in the bundle patch. * @module @deepseek-ai/dsh-web-app */ @@ -27,7 +27,6 @@ export const name = 'web-app' /** This dsh installation's root, from either this package's source or built entry. */ const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) -const HMR_ROW_NAME = '@deepseek-ai/dsh-client-hmr' /** Runtime service that releases Web rows after bind-dependent values resolve. */ const WEB_RUNTIME_SERVICE = 'webRuntime' @@ -35,19 +34,14 @@ const WEB_RUNTIME_SERVICE = 'webRuntime' /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] -/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ -export type WebMode = 'production' | 'development' - /** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { - /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ - mode: WebMode /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt - * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * non-interactive layer can turn it off when its user is not in the GUI, so the + * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive + * layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean @@ -56,7 +50,6 @@ export interface Config { } export const Config: z = z.object({ - mode: z.union([z.const('production'), z.const('development')]).default('production'), printUrl: z.boolean().default(true), surfaceContext: z.boolean().default(true), trustedHosts: z.array(String).default([]), @@ -72,8 +65,6 @@ export interface WebRuntimeValues { /** Environment variable naming the canonical local URL of this Web GUI. */ const DSH_WEB_URL = 'DSH_WEB_URL' as const -/** Environment variable naming the Web runtime mode. */ -const DSH_WEB_MODE = 'DSH_WEB_MODE' as const // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. @@ -101,13 +92,10 @@ export function resolveLanTrust(bindHost: string, extra: readonly string[]): Web } /** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ -function webSurfacePrompt(webUrl: string, mode: WebMode): string { - const updateContract = mode === 'development' - ? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. ' - + 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. ' - + 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. ' - : 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. ' - + 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. ' +function webSurfacePrompt(webUrl: string): string { + const updateContract = 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while ' + + '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. ' + + 'Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. ' return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. ' + 'The browser provides no implicit DOM, route, or screenshot context. ' @@ -139,38 +127,12 @@ function resolveDistIndex(): string { export const internals: { resolveDistIndex: () => string } = { resolveDistIndex } /** - * Mount the Web runtime: dist serving, surface prompt, bash runtime - * variables, the development-mode client-hmr row, and the URL line. + * Mount the Web runtime: dist serving, surface prompt, the bash runtime + * variable, and the URL line. * @param ctx - plugin context carrying the httpServer service. * @param config - validated {@link Config}. */ export function apply(ctx: Context, config: Config): void { - if (config.mode === 'development') { - // The dev reload chain is mounted as a real tree row so the browser - // roster scan includes its client half; it is a row rather than a child - // of this plugin because its node half is a client-side package, which a - // host-side bundle cannot import. Created in the root tree after Loader - // settlement: row creation must stay out of the mounting transaction, - // and a root-tree row survives user-patch reapplication of the include. - // The incremental roster scan picks it up before any page load — a - // browser arrives only after a human reads the URL line. - const loader = ctx.get('loader') - if (loader === undefined) { - ctx.logger.warn('web-app: development mode without a Loader tree mounts no client-hmr row') - } else { - void loader.await().then(async () => { - // The tree can be disposed while settlement was in flight (early - // SIGTERM); re-check before mutating it. The name scan spans every - // tree (entries() recurses into subtrees), so a row the user - // configured in a patch layer — enabled, reconfigured, or - // deliberately disabled — wins over this default, and a reload of - // this fiber never duplicates the row a previous generation created. - if (ctx.get('loader') === undefined) return - const mounted = [...ctx.loader.entries()].some(entry => entry.options.name === HMR_ROW_NAME) - if (!mounted) await ctx.loader.create({ name: HMR_ROW_NAME }) - }).catch((error: unknown) => { ctx.logger.error(error) }) - } - } const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts) // Release dependent rows only after bind-dependent trust has been sampled once. ctx.provide(WEB_RUNTIME_SERVICE, runtime) @@ -181,7 +143,7 @@ export function apply(ctx: Context, config: Config): void { promptCtx.systemPrompt.section({ name: 'app:web-surface', order: -98, - text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode), + text: () => webSurfacePrompt(localWebUrl(promptCtx)), }) }) ctx.inject(['bashEnv'], (runtimeCtx) => { @@ -189,9 +151,8 @@ export function apply(ctx: Context, config: Config): void { name: 'web-runtime', variables: { [DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' }, - [DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' }, }, - resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }), + resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx) }), }) }) } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index 040fe843c0..90de34b01d 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -1,6 +1,6 @@ /** * The web app's command-line provider: it parses the `dsh --profile web` flag - * family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help` + * family (`--host`, `--port`, `--trusted-host`) and its `--help` * text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}. * Ordinary rows inject that service before reading it from lazy config. * @module @deepseek-ai/dsh-web-app/startup @@ -25,8 +25,6 @@ export interface WebStartupValues { host?: string /** `--port`, absent when the invocation did not name one. */ port?: number - /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */ - mode: 'production' | 'development' /** Explicit `--trusted-host` authorities, in argument order. */ trustedHosts: string[] } @@ -35,7 +33,6 @@ export interface WebStartupValues { interface WebOptions { host?: string port?: string - dev?: boolean trustedHost?: string[] } @@ -50,14 +47,12 @@ function webCommand(): Command { .helpOption('-h, --help', 'show this help') .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') - .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .addHelpText('after', ` Examples: dsh --profile web serve on the composed host and port dsh --profile web --port 8080 serve on another port dsh --profile web --host 0.0.0.0 reach it from another machine on the LAN - dsh --profile web --dev mount the client-plugin HMR receiver `) } @@ -74,7 +69,6 @@ function planWebStartup(program: Command): WebStartupValues { return { ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, - mode: options.dev === true ? 'development' : 'production', trustedHosts: options.trustedHost ?? [], } } diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 5108d04232..a00405378a 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -57,7 +57,6 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ' config:', " host: !!js ctx.webStartup.host ?? '127.0.0.1'", ' port: !!js ctx.webStartup.port ?? 3080', - ' mode: !!js ctx.webStartup.mode', ' trustedHosts: !!js ctx.webStartup.trustedHosts', '- id: provider', ` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`, @@ -91,14 +90,12 @@ describe('web command-line provider', () => { const { values, observed } = await bootProvider([ '--host', '0.0.0.0', '--port', '8080', - '--dev', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9', ]) expect(values).toEqual({ host: '0.0.0.0', port: 8080, - mode: 'development', trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'], }) expect(observed.readerConfig).toEqual(values) @@ -107,11 +104,10 @@ describe('web command-line provider', () => { it('leaves deployment values to each consumer when flags omit them', async () => { const { values, observed } = await bootProvider([]) - expect(values).toEqual({ mode: 'production', trustedHosts: [] }) + expect(values).toEqual({ trustedHosts: [] }) expect(observed.readerConfig).toEqual({ host: '127.0.0.1', port: 3080, - mode: 'production', trustedHosts: [], }) }) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index 272ae54258..3940b54ed1 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -58,20 +58,9 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: return { server, seat: () => fallback } } -/** A fake Loader capturing the dev-mode row creation the runtime performs after settlement. */ -function provideHmrRow(ctx: Context, settle: () => Promise = async () => {}): string[] { - const created: string[] = [] - const entries: { options: { name: string } }[] = [] - ctx.provide('loader', { - entries: () => entries[Symbol.iterator](), - create: (options: { name: string }) => { - created.push(options.name) - entries.push({ options }) - return Promise.resolve(options.name) - }, - await: settle, - } as never) - return created +/** A fake Loader whose settlement the test controls (the URL line waits on it). */ +function provideLoader(ctx: Context, settle: () => Promise = async () => {}): void { + ctx.provide('loader', { await: settle } as never) } interface BashContribution { @@ -93,15 +82,14 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - const enabledRows = provideHmrRow(ctx) + provideLoader(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) + apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) expect(seat()).toBeDefined() // frontend-static claimed the fallback - expect(enabledRows).toEqual(['@deepseek-ai/dsh-client-hmr']) expect(ctx.get('webRuntime')).toEqual({ lanAddresses: ['192.168.1.5'], trustedHosts: ['192.168.1.5', 'lab.internal'], @@ -111,24 +99,26 @@ describe('web-app runtime glue', () => { expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') const section = assembly.sections.find(entry => entry.name === 'app:web-surface') expect(section?.text).toContain('http://127.0.0.1:4567') - expect(section?.text).toContain('--dev') + // The single update contract: the receiver is always on; no-refresh + // reloads additionally need the rebuild watcher. + expect(section?.text).toContain('pnpm run dev:web') const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime') - expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567', DSH_WEB_MODE: 'development' }) + expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' }) await ctx.fiber.dispose() }) - it('stays quiet in production mode with printUrl off and reports the production update contract', async () => { + it('stays quiet with printUrl off', async () => { stageDist() const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text) - .toContain('without `--dev`') + .toContain('rebuilding the affected Web artifacts') await ctx.fiber.dispose() }) @@ -143,7 +133,7 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] })) + apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() @@ -158,116 +148,12 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() }) - it('creates the client-hmr row exactly once across runtime reloads', async () => { - stageDist() - const ctx = new Context() - ctx.provide('httpServer', fakeHttpServer().server) - const created = provideHmrRow(ctx) - const mount = async (): Promise<() => Promise> => { - const fiber = ctx.plugin((child: Context) => { - apply(child, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] })) - }) - await fiber - await new Promise(resolve => setTimeout(resolve, 0)) - return () => fiber.dispose() - } - const disposeFirst = await mount() - expect(created).toEqual(['@deepseek-ai/dsh-client-hmr']) - await disposeFirst() - // A reload generation must not duplicate the row the previous one created. - const disposeSecond = await mount() - expect(created).toEqual(['@deepseek-ai/dsh-client-hmr']) - await disposeSecond() - await ctx.fiber.dispose() - }) - - it('defers to a user-configured client-hmr row anywhere in the tree', async () => { - stageDist() - const ctx = new Context() - ctx.provide('httpServer', fakeHttpServer().server) - const created: string[] = [] - // The user's own row — possibly patched into an include subtree and even - // disabled there — already carries the name; the runtime must not create - // a second one beside it. - ctx.provide('loader', { - entries: () => [{ options: { id: 'my-hmr', name: '@deepseek-ai/dsh-client-hmr', disabled: true } }][Symbol.iterator](), - create: (options: { name: string }) => { - created.push(options.name) - return Promise.resolve(options.name) - }, - await: () => Promise.resolve(), - } as never) - apply(ctx, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] })) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(created).toEqual([]) - await ctx.fiber.dispose() - }) - - it('skips the dev row when the tree is disposed during settlement and logs a creation failure', async () => { - stageDist() - const raced = new Context() - raced.provide('httpServer', fakeHttpServer().server) - let release!: () => void - const settlement = new Promise((resolve) => { release = resolve }) - const created: string[] = [] - const disposeLoader = raced.provide('loader', { - entries: () => [][Symbol.iterator](), - create: (options: { name: string }) => { - created.push(options.name) - return Promise.resolve(options.name) - }, - await: () => settlement, - } as never) - apply(raced, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] })) - disposeLoader() - release() - await new Promise(resolve => setTimeout(resolve, 0)) - expect(created).toEqual([]) - await raced.fiber.dispose() - - const failing = new Context() - failing.provide('httpServer', fakeHttpServer().server) - const failure = new Error('row creation failed') - failing.provide('loader', { - entries: () => [][Symbol.iterator](), - create: () => Promise.reject(failure), - await: () => Promise.resolve(), - } as never) - const errors: unknown[] = [] - failing.logger.error = ((error: unknown) => { errors.push(error) }) as typeof failing.logger.error - apply(failing, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] })) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(errors).toEqual([failure]) - await failing.fiber.dispose() - }) - - it('mounts no dev row in production and only warns without a Loader in development', async () => { - stageDist() - const prod = new Context() - prod.provide('httpServer', fakeHttpServer().server) - const created = provideHmrRow(prod) - apply(prod, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] })) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(created).toEqual([]) - await prod.fiber.dispose() - - const bare = new Context() - bare.provide('httpServer', fakeHttpServer().server) - const warnings: string[] = [] - bare.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof bare.logger.warn - apply(bare, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] })) - expect(warnings).toEqual(['web-app: development mode without a Loader tree mounts no client-hmr row']) - // Let the vitest invariant host settle before tearing the root down. - await new Promise(resolve => setTimeout(resolve, 0)) - await bare.fiber.dispose() - }) - it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { stageDist() // Settlement path: the line waits for loader.await() so supervisors can @@ -276,9 +162,9 @@ describe('web-app runtime glue', () => { settled.provide('httpServer', fakeHttpServer().server) let release: () => void const settlement = new Promise((resolve) => { release = resolve }) - provideHmrRow(settled, () => settlement) + provideLoader(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(settled, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() release!() @@ -291,8 +177,8 @@ describe('web-app runtime glue', () => { log.mockClear() const failed = new Context() failed.provide('httpServer', fakeHttpServer().server) - provideHmrRow(failed, async () => { throw new Error('boot failed') }) - apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) + provideLoader(failed, async () => { throw new Error('boot failed') }) + apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() await failed.fiber.dispose() @@ -307,8 +193,8 @@ describe('web-app runtime glue', () => { await child let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) - provideHmrRow(torn, () => tornSettlement) - apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) + provideLoader(torn, () => tornSettlement) + apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) @@ -324,7 +210,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('httpServer', server) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing') diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index da3e6eed6d..07bcf1d6a2 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/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/hmr/README.md -README.md: 9228292547376d3fbb0ea5ce56b9e0a35ced17b2 -README.zh.md: ea62600911458556a3dcc7c46854e97db751c3ef +README.md: c355595dd53ddcb74be629a6d5e730c6c5fcebbf +README.zh.md: 6ed4d0e79cb755f84784823749994b448ff209b8 diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index 9228292547..c355595dd5 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert. +Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle. The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `