From c44c6c93c81a9007d29e64820af2981fd728b48f Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 7 Aug 2026 14:33:18 +0800 Subject: [PATCH 01/77] feat(web): open workspace picker from composer --- ...ession-scope-and-provide-channel.i18n.yaml | 4 +-- ...lient-session-scope-and-provide-channel.md | 2 +- ...nt-session-scope-and-provide-channel.zh.md | 2 +- ...-workspace-picker-composer-entry.i18n.yaml | 6 ++++ ...6-08-07-workspace-picker-composer-entry.md | 29 +++++++++++++++ ...8-07-workspace-picker-composer-entry.zh.md | 29 +++++++++++++++ apps/web/tests/support.ts | 8 ++--- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 4 +-- packages/client/ui-conversation/README.zh.md | 4 +-- .../src/client/contract/slots.ts | 14 +++++--- .../src/client/skeleton/ConversationRoot.tsx | 9 +++-- .../src/client/skeleton/InputBar.module.css | 4 +++ .../src/client/skeleton/InputBar.tsx | 35 ++++++++++++++----- .../tests/assembly-surfaces.spec.tsx | 18 +++++++--- .../ui-conversation/tests/input-bar.spec.tsx | 26 ++++++++++++++ 16 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md create mode 100644 .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 9c8308be74..403453b3f7 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 3c51f06fca23a495f0fbc0cc4f1c289edea07b3b -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 06fc1005785d9d11b52839f91c3bb4b99cad7d63 +2026-07-25-web-client-session-scope-and-provide-channel.md: 42eaf47f08d0b1fe5abf4389accfdbae1097a529 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: c55decd8b92bbc954811cc08df3a6b28a5662258 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 3c51f06fca..42eaf47f08 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -132,5 +132,5 @@ Slot scope is the closed set `root | session-maybe | session`: - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. - Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reclamation rides lazy persistence's existing contract (evaporation on restart), and the ordinary ceiling rides same-Workspace reuse. -- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests; fully disabled input while no workspace is picked is an experience cost the product surface accepts (the price of the single state axis). +- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the resident textarea routes activation to the Workspace picker during that interval ([decision](../feature/2026-08-07-workspace-picker-composer-entry.md)). - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, awaiting its own branch). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 06fc100578..c55decd8b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -132,5 +132,5 @@ slot scope 是闭集 `root | session-maybe | session`: - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 - 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有契约(重启蒸发),常规上限靠同 Workspace 复用。 -- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住;「未选 workspace」期间输入全禁是产品面接受的体验代价(单一状态轴换来的)。 +- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,常驻 textarea 会把激活操作转到 Workspace 选择器([决策](../feature/2026-08-07-workspace-picker-composer-entry.md))。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,等独立分支)。 diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml new file mode 100644 index 0000000000..b3b26481c9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.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-07-workspace-picker-composer-entry.md +2026-08-07-workspace-picker-composer-entry.md: 42c24f86eac554ffd642199d6fe15b5d0513db28 +2026-08-07-workspace-picker-composer-entry.zh.md: 4ff38599df122ef8b866798aef3d43b7c848ea18 diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md new file mode 100644 index 0000000000..42c24f86ea --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md @@ -0,0 +1,29 @@ +# Agent Note: The no-Workspace composer opens the existing picker + +Status: implemented + +English | [中文](2026-08-07-workspace-picker-composer-entry.zh.md) + +## Problem + +The [session-scope decision](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) keeps one resident composer before a Workspace exists, but its textarea was disabled and only the smaller Workspace chip could open the picker. The largest and most familiar starting affordance therefore rejected the user's first click even though a recovery action was available on the same surface. + +## Decision + +While no Workspace owns the new Session, the resident textarea is read-only and activates the existing `conversation.hero.workspace` picker by pointer click, Enter, or Space. It exposes menu expansion state through `aria-haspopup` and `aria-expanded`. Message submission, command, permission, model, and other Session-scoped controls remain locked until Workspace selection creates or reconnects a real Session. + +Workspace selection retains the existing owner and flow. `ConversationRoot` opens the picker, `WorkspacePicker` lists or creates the Workspace, and the same textarea DOM node becomes the editable composer after the Session arrives. + +## Alternatives considered + +**Keep the textarea disabled and emphasize the Workspace chip.** This preserves the old control boundary but leaves the dominant composer surface inert during the first action. + +**Place a transparent button over the textarea.** A button has direct trigger semantics, but it creates a second focusable element over the resident textarea and complicates the DOM-identity transition that preserves focus, IME, and draft behavior. + +**Accept a draft before Workspace selection.** This would require a client-owned draft Session or another pre-Session state axis. The feature only needs a discoverable path into the existing picker. + +## Consequences + +The first composer click now continues the required setup flow, and keyboard users can activate the same path. The textarea accurately reports read-only state until a Session exists, while adjacent controls remain disabled. The UI introduces no new Workspace state, transport, or directory-selection flow. + +Component coverage pins pointer and keyboard activation, locked adjacent controls, picker expansion, and the same-node transition to an editable textarea. The assembled Web helper begins fresh Workspace setup through the textarea, so replayed browser scenarios exercise the shipped path. diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md new file mode 100644 index 0000000000..4ff38599df --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 未选择 Workspace 时从编辑器打开现有选择器 + +Status: implemented + +[English](2026-08-07-workspace-picker-composer-entry.md) | 中文 + +## 问题 + +[Session scope 决策](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md)会在 Workspace 存在前保留同一个常驻编辑器,但 textarea 处于禁用状态,只有较小的 Workspace chip 能打开选择器。用户首次点击最显眼、也最熟悉的输入区域时,界面不会响应,尽管同一界面已有继续操作的入口。 + +## 决策 + +新会话尚未归属任何 Workspace 时,常驻 textarea 为只读状态,并可通过鼠标点击、Enter 或 Space 激活现有的 `conversation.hero.workspace` 选择器。它通过 `aria-haspopup` 和 `aria-expanded` 暴露菜单展开状态。消息提交、命令、权限、模型及其他 Session 作用域控件会保持锁定,直到用户选择 Workspace 并创建或重新连接真实 Session。 + +Workspace 选择继续使用现有 owner 和流程。`ConversationRoot` 打开选择器,`WorkspacePicker` 列出或创建 Workspace;Session 到达后,同一个 textarea DOM 节点变为可编辑状态。 + +## 考虑过的替代方案 + +**保持 textarea 禁用并突出 Workspace chip。** 这样能保留原有控件边界,但首次操作时最主要的编辑器区域仍然没有响应。 + +**在 textarea 上方放置透明按钮。** 按钮具备直接的触发器语义,但它会在常驻 textarea 上方增加第二个可聚焦元素,并使保留焦点、输入法和草稿行为的 DOM identity 过渡更复杂。 + +**在选择 Workspace 前接收草稿。** 这需要由 client 拥有的草稿 Session 或另一条 Session 前状态轴。此功能只需要提供一个更容易发现的现有选择器入口。 + +## 后果 + +用户首次点击编辑器即可继续必要的设置流程,键盘用户也能激活同一路径。textarea 会如实报告只读状态,直到 Session 存在;相邻控件仍处于禁用状态。界面没有引入新的 Workspace 状态、传输或目录选择流程。 + +组件测试会固定鼠标和键盘激活、相邻控件锁定、选择器展开,以及同一节点变为可编辑 textarea 的过渡。组装后的 Web helper 会通过 textarea 开始全新 Workspace 设置,因此重放浏览器场景会覆盖实际交付路径。 diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 1b7b67aab3..36b65db8b6 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -58,9 +58,9 @@ export function probeFreePort(): Promise { /** * Drive the hero's workspace picker through the composed directory dialog * until the live composer unlocks. A fresh world has no Workspace, so the boot - * lands in the locked view state (startup auto-selection has nothing to + * lands in the Workspace-trigger view state (startup auto-selection has nothing to * select); every scenario that types into the composer must connect one - * first. With nothing to list, the chip gesture raises the dialog directly — + * first. With nothing to list, activating the textarea raises the dialog directly — * adding a workspace is the picker's only entry. The directory is staged here * and adopted through the path editor, which is idempotent across the repeated * connects a scenario may make; creating a folder from inside the dialog (the @@ -74,7 +74,7 @@ export function probeFreePort(): Promise { */ export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('button', { name: 'Choose workspace' }).click() + await page.getByRole('textbox', { name: 'Choose workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Edit path' }).click() @@ -98,7 +98,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo */ export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('button', { name: '选择工作区' }).click() + await page.getByRole('textbox', { name: '选择工作区' }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: '编辑路径' }).click() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f5ad20c3fc..873a6977f7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: a75f25d8669cd688795842a655106e0e27bb7173 -README.zh.md: f0d744c31020730857d210d75749b907dbffca08 +README.md: 73e638ddde4ad7be73a50cfba7e19cbe09a548b8 +README.zh.md: 80e9fd7dd82a68d0223be61ecbcdce18214a6fdd diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index a75f25d866..73e638ddde 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it keeps message actions locked and presents the resident textarea as a read-only Workspace-picker trigger; its root-scoped `conversation.hero.workspace` slot hosts the existing picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. @@ -44,7 +44,7 @@ Keyboard message submission resolves delivery from the addressed session's runni Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop) while the read-only textarea opens the existing Workspace picker by click, Enter, or Space. It does not swap in a parallel tree, so the textarea DOM survives Workspace selection; the strict-session control seats simply stay empty until a session exists. The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f0d744c310..80e9fd7dd8 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让常驻 textarea 成为只读的 Workspace 选择器入口;其根作用域的 `conversation.hero.workspace` slot 承载现有选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 @@ -44,7 +44,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),只读 textarea 则可通过点击、Enter 或 Space 打开现有 Workspace 选择器。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a84b4a3bf0..64106ad85f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -83,8 +83,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * takeover election hides rather than unmounts it and the textarea DOM * survives). Session-maybe: the bar stays mounted across the * no-session/session transition — the no-workspace hero renders the SAME - * textarea DOM disabled instead of a parallel inert tree — with the - * machine hooks absent until a session is current. InputBar registers + * textarea DOM as a read-only Workspace-picker trigger instead of a + * parallel inert tree — with the machine hooks absent until a session is + * current. InputBar registers * here from this package's apply; its machine state arrives through the * standard provide channel (useInput + inputActions), the keyboard * command face through its own inject. @@ -259,11 +260,14 @@ export interface ComposerBarOwnerProps { /** Hero = empty-state centered card; composer = resident bottom bar. */ variant: 'hero' | 'composer' /** - * Inert no-workspace state: the bar renders its normal DOM fully disabled - * (textarea, add, send) so the workspace pick transitions in place instead - * of swapping component trees. + * Inert no-workspace state: the bar locks message actions while preserving + * its normal DOM so the Workspace pick transitions in place. */ disabled?: boolean + /** Whether the Workspace picker opened from this bar is currently expanded. */ + workspacePickerOpen?: boolean + /** Open the existing Workspace picker from the inert textarea. */ + onRequestWorkspace?: () => void placeholder?: string /** Optional content rendered above the textarea. */ accessory?: ReactNode diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index bc5f7bdc01..18873a47e0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -120,7 +120,7 @@ export function ConversationRoot({ ) - // The placeholder chip ("Choose workspace") and the inert input travel + // The placeholder chip ("Choose workspace") and the Workspace-trigger input travel // together: no workspace picked yet (cold start, no session at all), or a // blank session whose workspace vanished (deleted from the sidebar). The // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, @@ -129,7 +129,12 @@ export function ConversationRoot({ const inputBar = renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', ...(inert - ? { disabled: true, placeholder: t('placeholder.workspace') } + ? { + disabled: true, + placeholder: t('placeholder.workspace'), + workspacePickerOpen: pickerOpen, + onRequestWorkspace: () => { setPickerOpen(true) }, + } : hero ? { placeholder: t('placeholder.hero') } : {}), overlay: renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), 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 6ab387c3eb..ca5aab43e5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -243,6 +243,10 @@ cursor: not-allowed; } +.input[aria-haspopup='menu'] { + cursor: pointer; +} + .mirror { visibility: hidden; pointer-events: none; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 131f63c49d..a90b37c449 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -37,7 +37,9 @@ export type InputBarProps = ComposerBarProps export function InputBar({ useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t, renderSlot, useNotices, useLexicon, useMenuLauncher, - useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, + useProjection, sessionId, variant, disabled: inert = false, + workspacePickerOpen = false, onRequestWorkspace, + placeholder, accessory, overlay, leftItems, rightItems, footer, }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) @@ -89,6 +91,12 @@ export function InputBar({ const disabled = removed || inert || !live const locked = disabled const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' + // The no-workspace textarea remains the resident DOM node but acts as the + // existing picker trigger. Message controls stay locked until a Session + // exists; the trigger itself is read-only rather than disabled so pointer + // and keyboard users can reach the recovery action. + const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined + const textareaDisabled = removed || (locked && !workspaceTrigger) // Scroll the draft scrollport the minimum that brings `caret` into view — the // browser's own behavior for typing, performed for the paths where it does @@ -200,8 +208,15 @@ export function InputBar({ }, []) const onKeyDown = (e: KeyboardEvent): void => { - // Absent machine (no session): the textarea is disabled so events cannot - // fire; the guard narrows the faces for the paths below. + if (workspaceTrigger) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onRequestWorkspace() + } + return + } + // Absent machine without a Workspace recovery action stays disabled; the + // guard narrows the faces for the paths below. if (keyboard === undefined || inputActions === undefined) return // Shift+Enter is the native newline UNCONDITIONALLY — decided before the // IME guard so a composition-closing Shift+Enter still breaks the line. @@ -255,7 +270,7 @@ export function InputBar({ } const onChange = (e: ChangeEvent): void => { - if (keyboard === undefined) return // absent machine: disabled textarea, no events + if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock const next = e.target.value keyboard.setDraft(next) @@ -281,7 +296,7 @@ export function InputBar({ /* oxlint-enable typescript/no-unnecessary-condition */ const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { - if (input === undefined || keyboard === undefined) return // absent machine: disabled textarea, no events + if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut const el = e.currentTarget const { start, end } = selectionOf(el) if (start === end) return @@ -306,7 +321,7 @@ export function InputBar({ } const onPaste = (e: React.ClipboardEvent): void => { - if (keyboard === undefined) return // absent machine: disabled textarea, no events + if (keyboard === undefined) return // absent machine: no draft can accept a paste if (machineBusy || locked) return const text = e.clipboardData.getData('text/plain') if (text === '') return @@ -469,8 +484,11 @@ export function InputBar({ ref={inputRef} className={css.input} value={draft} - disabled={locked} - readOnly={machineBusy} + disabled={textareaDisabled} + readOnly={machineBusy || workspaceTrigger} + aria-label={workspaceTrigger ? t('hero.chooseWorkspace') : undefined} + aria-haspopup={workspaceTrigger ? 'menu' : undefined} + aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined} data-phase={input?.phase ?? 'inert'} placeholder={placeholder ?? (disabled ? t('placeholder.unavailable') @@ -478,6 +496,7 @@ export function InputBar({ rows={2} onChange={onChange} onKeyDown={onKeyDown} + onClick={workspaceTrigger ? onRequestWorkspace : undefined} onSelect={onSelect} onCopy={(e) => { onCopyOrCut(e, false) }} onCut={(e) => { onCopyOrCut(e, true) }} diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 16163065eb..9a0974e6a2 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -189,12 +189,20 @@ describe('resident composer', () => { runtime.slots.installLocale(locale) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...inject], apply }) + runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe) const view = runtime.renderRoot() - // No session entity: the inert twin renders (disabled textarea), and the - // workspace picker chip is the only live control. const textarea = view.container.querySelector('textarea') expect(textarea).not.toBeNull() - expect(textarea!.disabled).toBe(true) + expect(textarea!.disabled).toBe(false) + expect(textarea!.readOnly).toBe(true) + expect(textarea!.getAttribute('aria-haspopup')).toBe('menu') + expect(view.getByTestId('workspace-probe').textContent).toBe('false:0') + fireEvent.click(textarea!) + expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') + expect(textarea!.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(view.getByRole('button', { name: '选择工作区' })) + fireEvent.keyDown(textarea!, { key: 'Enter' }) + expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy() await runtime.dispose() }) @@ -219,7 +227,8 @@ describe('resident composer', () => { const textarea = view.container.querySelector('textarea')! const workspaceChip = view.getByRole('button', { name: '选择工作区' }) const workspaceProbe = view.getByTestId('workspace-probe') - expect(textarea.disabled).toBe(true) + expect(textarea.disabled).toBe(false) + expect(textarea.readOnly).toBe(true) fireEvent.click(workspaceChip) fireEvent.click(workspaceProbe) @@ -239,6 +248,7 @@ describe('resident composer', () => { expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe) expect(workspaceProbe.textContent).toBe('true:1') expect(textarea.disabled).toBe(false) + expect(textarea.readOnly).toBe(false) await runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index bc276174a3..f42030d2ca 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -55,6 +55,9 @@ interface BenchOptions { running?: boolean subagent?: Exclude disabled?: boolean + inert?: boolean + workspacePickerOpen?: boolean + onRequestWorkspace?: () => void promptError?: ConversationSnapshot['promptError'] variant?: 'hero' | 'composer' placeholder?: string @@ -135,6 +138,9 @@ function bench(over?: BenchOptions) { t: over?.t ?? makeTranslate(zh, commonZh), renderSlot, variant: over?.variant ?? 'composer', + ...(over?.inert === true ? { disabled: true } : {}), + ...(over?.workspacePickerOpen !== undefined ? { workspacePickerOpen: over.workspacePickerOpen } : {}), + ...(over?.onRequestWorkspace !== undefined ? { onRequestWorkspace: over.onRequestWorkspace } : {}), ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), ...(over?.accessory !== undefined ? { accessory: over.accessory } : {}), ...(over?.overlay !== undefined ? { overlay: over.overlay } : {}), @@ -539,6 +545,26 @@ describe('running and lock semantics (queue cut 1)', () => { expect(custom.textarea.placeholder).toBe('Custom placeholder') }) + it('the inert textarea opens the Workspace picker by pointer or keyboard', () => { + const onRequestWorkspace = vi.fn() + const { view, textarea } = bench({ + inert: true, + workspacePickerOpen: false, + onRequestWorkspace, + placeholder: '选择一个工作区开始', + }) + expect(textarea.disabled).toBe(false) + expect(textarea.readOnly).toBe(true) + expect(textarea.getAttribute('aria-haspopup')).toBe('menu') + expect(textarea.getAttribute('aria-expanded')).toBe('false') + expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + + fireEvent.click(textarea) + fireEvent.keyDown(textarea, { key: 'Enter' }) + fireEvent.keyDown(textarea, { key: ' ' }) + expect(onRequestWorkspace).toHaveBeenCalledTimes(3) + }) + it('the plan projection swaps the placeholder while its effective target is plan mode', () => { const active = bench({ plan: { active: true, pending: false } }) expect(active.textarea.placeholder).toBe('描述你的任务以生成计划') From 0a248bcea84afa5e98b347ad27a81f4d81047edc Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 7 Aug 2026 17:06:01 +0800 Subject: [PATCH 02/77] feat(web): make the whole no-workspace composer card the picker trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pick target grows from the read-only textarea to the full capsule: the card owns the click, its disabled controls let pointer events fall through, and a dashed l4 stroke with a business-blue hover marks the affordance (an SVG dash ring — native dashed has a fixed pattern). The card contains pointerdown so the open picker's outside-close cannot race the reopen, which flickered the chip's expansion echo. --- ...-workspace-picker-composer-entry.i18n.yaml | 4 +-- ...6-08-07-workspace-picker-composer-entry.md | 4 +-- ...8-07-workspace-picker-composer-entry.zh.md | 4 +-- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 4 +-- packages/client/ui-conversation/README.zh.md | 4 +-- .../src/client/skeleton/InputBar.module.css | 34 +++++++++++++++++++ .../src/client/skeleton/InputBar.tsx | 13 +++++-- .../ui-conversation/tests/input-bar.spec.tsx | 14 ++++++++ 9 files changed, 71 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml index b3b26481c9..166af490b6 100644 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.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-07-workspace-picker-composer-entry.md -2026-08-07-workspace-picker-composer-entry.md: 42c24f86eac554ffd642199d6fe15b5d0513db28 -2026-08-07-workspace-picker-composer-entry.zh.md: 4ff38599df122ef8b866798aef3d43b7c848ea18 +2026-08-07-workspace-picker-composer-entry.md: f4b619d4fb56c889e765b613fefe9ea24bff7112 +2026-08-07-workspace-picker-composer-entry.zh.md: 6c264ab89f9c7b0378b23343999a861aa8f0714f diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md index 42c24f86ea..f4b619d4fb 100644 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md @@ -10,7 +10,7 @@ The [session-scope decision](../architecture/2026-07-25-web-client-session-scope ## Decision -While no Workspace owns the new Session, the resident textarea is read-only and activates the existing `conversation.hero.workspace` picker by pointer click, Enter, or Space. It exposes menu expansion state through `aria-haspopup` and `aria-expanded`. Message submission, command, permission, model, and other Session-scoped controls remain locked until Workspace selection creates or reconnects a real Session. +While no Workspace owns the new Session, the whole composer card activates the existing `conversation.hero.workspace` picker by pointer click — the card owns the click handler and its disabled controls let pointer events fall through, so the full capsule is one target — and the read-only resident textarea does the same by Enter or Space, exposing menu expansion state through `aria-haspopup` and `aria-expanded`. A dashed l4 stroke (an SVG dash ring, since native `dashed` has a fixed pattern) with a business-blue hover marks the card as the pick affordance. The card contains `pointerdown`, so the open picker's outside-close cannot race the click's reopen — that close-then-open flickered the chip's expansion echo. Message submission, command, permission, model, and other Session-scoped controls remain locked until Workspace selection creates or reconnects a real Session. Workspace selection retains the existing owner and flow. `ConversationRoot` opens the picker, `WorkspacePicker` lists or creates the Workspace, and the same textarea DOM node becomes the editable composer after the Session arrives. @@ -26,4 +26,4 @@ Workspace selection retains the existing owner and flow. `ConversationRoot` open The first composer click now continues the required setup flow, and keyboard users can activate the same path. The textarea accurately reports read-only state until a Session exists, while adjacent controls remain disabled. The UI introduces no new Workspace state, transport, or directory-selection flow. -Component coverage pins pointer and keyboard activation, locked adjacent controls, picker expansion, and the same-node transition to an editable textarea. The assembled Web helper begins fresh Workspace setup through the textarea, so replayed browser scenarios exercise the shipped path. +Component coverage pins pointer and keyboard activation, the card-wide click target, the contained `pointerdown`, locked adjacent controls, picker expansion, and the same-node transition to an editable textarea. The assembled Web helper begins fresh Workspace setup through the textarea, so replayed browser scenarios exercise the shipped path. diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md index 4ff38599df..6c264ab89f 100644 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -新会话尚未归属任何 Workspace 时,常驻 textarea 为只读状态,并可通过鼠标点击、Enter 或 Space 激活现有的 `conversation.hero.workspace` 选择器。它通过 `aria-haspopup` 和 `aria-expanded` 暴露菜单展开状态。消息提交、命令、权限、模型及其他 Session 作用域控件会保持锁定,直到用户选择 Workspace 并创建或重新连接真实 Session。 +新会话尚未归属任何 Workspace 时,整张输入卡片都可通过鼠标点击激活现有的 `conversation.hero.workspace` 选择器——点击处理器归卡片所有,其禁用控件放行指针事件,因此整个胶囊是同一个目标;只读的常驻 textarea 也可经 Enter 或 Space 激活,并通过 `aria-haspopup` 和 `aria-expanded` 暴露菜单展开状态。虚线 l4 描边(SVG dash ring,因为原生 `dashed` 的间距不可调)配合 hover 时的 business 蓝,把卡片标记为选择入口。卡片会拦下 `pointerdown`,使已打开选择器的外点关闭无法与点击的重新打开竞态——先关后开会让 chip 的展开回显闪动。消息提交、命令、权限、模型及其他 Session 作用域控件会保持锁定,直到用户选择 Workspace 并创建或重新连接真实 Session。 Workspace 选择继续使用现有 owner 和流程。`ConversationRoot` 打开选择器,`WorkspacePicker` 列出或创建 Workspace;Session 到达后,同一个 textarea DOM 节点变为可编辑状态。 @@ -26,4 +26,4 @@ Workspace 选择继续使用现有 owner 和流程。`ConversationRoot` 打开 用户首次点击编辑器即可继续必要的设置流程,键盘用户也能激活同一路径。textarea 会如实报告只读状态,直到 Session 存在;相邻控件仍处于禁用状态。界面没有引入新的 Workspace 状态、传输或目录选择流程。 -组件测试会固定鼠标和键盘激活、相邻控件锁定、选择器展开,以及同一节点变为可编辑 textarea 的过渡。组装后的 Web helper 会通过 textarea 开始全新 Workspace 设置,因此重放浏览器场景会覆盖实际交付路径。 +组件测试会固定鼠标和键盘激活、覆盖整卡的点击目标、被拦下的 `pointerdown`、相邻控件锁定、选择器展开,以及同一节点变为可编辑 textarea 的过渡。组装后的 Web helper 会通过 textarea 开始全新 Workspace 设置,因此重放浏览器场景会覆盖实际交付路径。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 873a6977f7..0a9f7e1cf7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 73e638ddde4ad7be73a50cfba7e19cbe09a548b8 -README.zh.md: 80e9fd7dd82a68d0223be61ecbcdce18214a6fdd +README.md: 899afe5f9bef83ece6d1dd84ae88b5c64b7cc9ad +README.zh.md: c6213eb76249870dfa59ea6b9986a10194b7670e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 73e638ddde..899afe5f9b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. -The resident conversation shell survives no-session and session transitions. Without a current session it keeps message actions locked and presents the resident textarea as a read-only Workspace-picker trigger; its root-scoped `conversation.hero.workspace` slot hosts the existing picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it keeps message actions locked and presents the whole composer card — dashed-border affordance, resident textarea read-only — as a Workspace-picker trigger; its root-scoped `conversation.hero.workspace` slot hosts the existing picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. @@ -44,7 +44,7 @@ Keyboard message submission resolves delivery from the addressed session's runni Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop) while the read-only textarea opens the existing Workspace picker by click, Enter, or Space. It does not swap in a parallel tree, so the textarea DOM survives Workspace selection; the strict-session control seats simply stay empty until a session exists. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop) while the whole dashed-border card opens the existing Workspace picker by click (its disabled controls let pointer events fall through, and the card contains `pointerdown` so the open picker's outside-close cannot race the reopen) and the read-only textarea does the same by Enter or Space. It does not swap in a parallel tree, so the textarea DOM survives Workspace selection; the strict-session control seats simply stay empty until a session exists. The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 80e9fd7dd8..c6213eb762 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让常驻 textarea 成为只读的 Workspace 选择器入口;其根作用域的 `conversation.hero.workspace` slot 承载现有选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张输入卡片——虚线边框示能、常驻 textarea 只读——成为 Workspace 选择器入口;其根作用域的 `conversation.hero.workspace` slot 承载现有选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 @@ -44,7 +44,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),只读 textarea 则可通过点击、Enter 或 Space 打开现有 Workspace 选择器。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线边框卡片则可通过点击打开现有 Workspace 选择器(其禁用控件放行指针事件;卡片并拦下 `pointerdown`,使已打开选择器的外点关闭不会与重新打开竞态),只读 textarea 也可经 Enter 或 Space 打开。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 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 ca5aab43e5..9774e612c9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -102,6 +102,40 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +/* No-workspace trigger state: dashed l4 stroke marks the card as a pick-a- + workspace affordance rather than a live composer; hover answers in the + business blue to invite the click. Native `dashed` has a fixed browser + pattern, so the stroke is an ::after overlay: theme-token background masked + by an SVG dash ring (stroke-width 2 centered on the box edge = 1px visible + inside), which keeps the 22px radius and both themes. */ +.cardWorkspaceTrigger { + border-color: transparent; + cursor: pointer; +} + +.cardWorkspaceTrigger::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: 22px; + background: var(--dsw-alias-border-l4); + transition: background-color 100ms ease; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='none' rx='22' ry='22' stroke='black' stroke-width='2' stroke-dasharray='4 4'/%3E%3C/svg%3E"); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='none' rx='22' ry='22' stroke='black' stroke-width='2' stroke-dasharray='4 4'/%3E%3C/svg%3E"); + pointer-events: none; +} + +/* Disabled toolbar controls neither receive nor swallow clicks in the trigger + state: pointer events fall through to the card's own click handler, making + the full capsule one pick target. */ +.cardWorkspaceTrigger :disabled { + pointer-events: none; +} + +.cardWorkspaceTrigger:hover::after { + background: var(--dsw-alias-state-business-primary); +} + .accessory { display: flex; align-items: center; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index a90b37c449..44570056fb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -467,7 +467,17 @@ export function InputBar({ {notice.text} )} -
+ {/* Trigger clicks land on the card, not the textarea: the toolbar row's + disabled controls swallow clicks otherwise (the CSS state disarms + 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). */} +
{ e.stopPropagation() } : undefined} + > {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the @@ -496,7 +506,6 @@ export function InputBar({ rows={2} onChange={onChange} onKeyDown={onKeyDown} - onClick={workspaceTrigger ? onRequestWorkspace : undefined} onSelect={onSelect} onCopy={(e) => { onCopyOrCut(e, false) }} onCut={(e) => { onCopyOrCut(e, true) }} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f42030d2ca..5d15fcb7b4 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -563,6 +563,20 @@ describe('running and lock semantics (queue cut 1)', () => { fireEvent.keyDown(textarea, { key: 'Enter' }) fireEvent.keyDown(textarea, { key: ' ' }) expect(onRequestWorkspace).toHaveBeenCalledTimes(3) + + // The WHOLE capsule is the pick target, and its pointerdown never reaches + // the document — the open picker's outside-close must not race the reopen. + const card = view.container.querySelector('[data-composer-card]') as HTMLElement + fireEvent.click(card) + expect(onRequestWorkspace).toHaveBeenCalledTimes(4) + const onDocumentPointerDown = vi.fn() + document.addEventListener('pointerdown', onDocumentPointerDown) + try { + fireEvent.pointerDown(card) + } finally { + document.removeEventListener('pointerdown', onDocumentPointerDown) + } + expect(onDocumentPointerDown).not.toHaveBeenCalled() }) it('the plan projection swaps the placeholder while its effective target is plan mode', () => { From eab0aeb9dbf5c16c996b3aa7b8fc4d6e9a33b76a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 23:29:41 +0800 Subject: [PATCH 03/77] feat(web): list background tasks in the session header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task registry has run every background bash, pwsh, pty-send, and one-shot subagent since it landed, but only the model could read it: a human at the Web client could not see that a build was running, tell a finished task from a stuck one, or find its outcome anywhere but the `run_in_background` tool card that printed an id and never updated. Task state now reaches the browser as one whole-snapshot `session/tasks` mux frame per session, pushed at every registry commit that changes what that session can see. `TaskService` gains `onTasksChanged`, which is owner-granular because owner-disposal removal is a change no per-task record can express. The carrier reads the exact owner the listener hands it, so a push stays correct while that scope tears down, and reads the baseline through the non-resuming `ctx.agents.get` so listing never revives a cold session. The client keeps a last-wins mirror on `SessionListState`, and a new `dsh-client-ui-task` package renders it beside the subagent catalog — rendering nothing at all until the session has a task, so an ordinary conversation grows no new chrome. Streamed per-task output and human-initiated cancellation are separate phases; the note records why neither has to undo this channel, and why no Web path may call the consuming `ctx.tasks.read()`. --- ...8-08-web-background-task-display.i18n.yaml | 6 + .../2026-08-08-web-background-task-display.md | 136 ++++++++++ ...26-08-08-web-background-task-display.zh.md | 136 ++++++++++ apps/web/tests/background-task-list.e2e.ts | 130 ++++++++++ .../background-task-list/running.expected.md | 2 + .../background-task-list/settled.expected.md | 2 + apps/web/tsconfig.json | 1 + docs/config-catalog.md | 1 + docs/cordis-catalog/services.md | 20 +- docs/core-data-structures/tasks.i18n.yaml | 4 +- docs/core-data-structures/tasks.md | 2 +- docs/core-data-structures/tasks.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 4 + packages/bundle/web-app/package.json | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + packages/client/connection/src/client/api.ts | 1 + .../client/connection/src/client/index.ts | 1 + .../client/locale/tests/language-row.spec.tsx | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/sessions/manager.ts | 28 +- .../runtime/src/client/sessions/service.ts | 14 +- packages/client/runtime/tests/manager.spec.ts | 57 +++++ packages/client/test-runtime/src/sessions.ts | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/enter-behavior-row.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- .../tests/conversation-ui.spec.tsx | 1 + packages/client/ui-task/README.i18n.yaml | 6 + packages/client/ui-task/README.md | 24 ++ packages/client/ui-task/README.zh.md | 24 ++ packages/client/ui-task/package.json | 68 +++++ .../src/client/TaskListAction.module.css | 116 +++++++++ .../ui-task/src/client/TaskListAction.tsx | 185 ++++++++++++++ packages/client/ui-task/src/client/index.ts | 40 +++ packages/client/ui-task/src/client/locales.ts | 45 ++++ packages/client/ui-task/src/css-modules.d.ts | 6 + packages/client/ui-task/src/index.ts | 9 + packages/client/ui-task/src/invariant.ts | 32 +++ .../ui-task/tests/browser-plugin.spec.ts | 89 +++++++ .../ui-task/tests/task-list-action.spec.tsx | 239 ++++++++++++++++++ packages/client/ui-task/tsconfig.json | 33 +++ packages/client/ui-task/tsdown.config.ts | 3 + .../ui-theme/tests/appearance-row.spec.tsx | 2 +- .../ui-tool/tests/chat-code-subcalls.spec.tsx | 2 +- .../ui-tool/tests/coverage-tails.spec.tsx | 2 +- .../client/ui-tool/tests/diff-card.spec.tsx | 6 +- .../client/ui-tool/tests/read-card.spec.tsx | 6 +- .../client/ui-tool/tests/search-card.spec.tsx | 2 +- .../ui-tool/tests/terminal-card.spec.tsx | 8 +- .../client/ui-tool/tests/web-card.spec.tsx | 2 +- .../client/ui-trajectory/tests/views.spec.tsx | 2 +- .../client/ui-workspace/tests/tree.spec.ts | 2 +- .../tests/workspace-browser.spec.tsx | 2 +- .../tests/workspace-picker.spec.tsx | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 52 +++- .../host/apiproxy/src/api/events.schema.ts | 2 + packages/host/apiproxy/src/api/events.ts | 15 ++ packages/host/apiproxy/src/api/index.ts | 1 + .../host/apiproxy/src/api/tasks.schema.ts | 33 +++ packages/host/apiproxy/src/api/tasks.ts | 36 +++ .../apiproxy/tests/api-proxy-tasks.spec.ts | 206 +++++++++++++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 13 + packages/host/apiproxy/tsconfig.json | 3 + packages/tasks/tasks-local/src/index.ts | 41 ++- .../tasks/tasks-local/tests/tasks.spec.ts | 93 +++++++ packages/tasks/tasks/README.i18n.yaml | 4 +- packages/tasks/tasks/README.md | 1 + packages/tasks/tasks/README.zh.md | 1 + packages/tasks/tasks/package.json | 5 + packages/tasks/tasks/src/brand.ts | 28 ++ packages/tasks/tasks/src/index.ts | 21 +- packages/tasks/tasks/src/types.ts | 28 +- packages/tasks/tasks/tests/service.spec.ts | 10 +- pnpm-lock.yaml | 40 +++ scripts/gen-cordis-catalog.ts | 1 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 2 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 93 files changed, 2130 insertions(+), 68 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-web-background-task-display.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md create mode 100644 apps/web/tests/background-task-list.e2e.ts create mode 100644 apps/web/tests/snapshots/background-task-list/running.expected.md create mode 100644 apps/web/tests/snapshots/background-task-list/settled.expected.md create mode 100644 packages/client/ui-task/README.i18n.yaml create mode 100644 packages/client/ui-task/README.md create mode 100644 packages/client/ui-task/README.zh.md create mode 100644 packages/client/ui-task/package.json create mode 100644 packages/client/ui-task/src/client/TaskListAction.module.css create mode 100644 packages/client/ui-task/src/client/TaskListAction.tsx create mode 100644 packages/client/ui-task/src/client/index.ts create mode 100644 packages/client/ui-task/src/client/locales.ts create mode 100644 packages/client/ui-task/src/css-modules.d.ts create mode 100644 packages/client/ui-task/src/index.ts create mode 100644 packages/client/ui-task/src/invariant.ts create mode 100644 packages/client/ui-task/tests/browser-plugin.spec.ts create mode 100644 packages/client/ui-task/tests/task-list-action.spec.tsx create mode 100644 packages/client/ui-task/tsconfig.json create mode 100644 packages/client/ui-task/tsdown.config.ts create mode 100644 packages/host/apiproxy/src/api/tasks.schema.ts create mode 100644 packages/host/apiproxy/src/api/tasks.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-tasks.spec.ts create mode 100644 packages/tasks/tasks/src/brand.ts diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml new file mode 100644 index 0000000000..f00211b5f4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.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-08-web-background-task-display.md +2026-08-08-web-background-task-display.md: 17ac109082ab3b2afea92194d0d148cf11ba9d0a +2026-08-08-web-background-task-display.zh.md: 1049218337caae3bd95c6f6ff3183589d0ce9d15 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md new file mode 100644 index 0000000000..17ac109082 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md @@ -0,0 +1,136 @@ +# Agent Note: Web background-task display + +Status: implemented + +English | [中文](2026-08-08-web-background-task-display.zh.md) + +## Problem + +`ctx.tasks` already runs every long-lived piece of work the harness starts in the background — `bash`, `pwsh`, `pty-send`, and one-shot background subagents — but its only reader was the model. [`dsh-tool-tasks`](../../../../packages/tasks/tool-tasks/README.md) exposes `task_list`, `task_output`, and `task_kill`, and nothing else observed the registry. + +A human at the Web client therefore could not see that a build was running, could not distinguish a finished task from a stuck one, and could not stop one. The only trace was the `run_in_background` tool card that printed a task id somewhere earlier in the transcript, and that card never updates again. + +The session header was already the place where per-session background activity lives: [`dsh-client-ui-subagent`](../../../../packages/client/ui-subagent/README.md) contributes the subagent catalog to `conversation.session.header.actions`. Placement was settled. What was missing was any channel at all that carried task state to a browser. + +## Decision + +Task state reaches the browser as **one whole-snapshot mux frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping. + +This ships the list alone. Per-task streamed output and a human-initiated cancellation are separate phases, and the channel is shaped so neither has to undo it. + +### Wire shape + +One frame in the mux stream: + +```ts ignore-check +| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] } +``` + +`TaskView` is browser-safe and owned by the carrier at [`packages/host/apiproxy/src/api/tasks.ts`](../../../../packages/host/apiproxy/src/api/tasks.ts), alongside the other domain contracts, with its wire schema beside it in `tasks.schema.ts`: + +```ts +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' + +export interface TaskView { + id: TaskId + kind: string + label: string + status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + detail?: string + startedAt: number + finishedAt?: number +} +``` + +`TaskId` comes from the cordis-free [`@deepseek-ai/dsh-tasks/brand`](../../../../packages/tasks/tasks/src/brand.ts) leaf — the same arrangement as the `@deepseek-ai/dsh-llm/brand` import `api/subagents.ts` already uses, because the `dsh-tasks` root reaches `dsh-agent` and is unreachable from a client program even as a type. Like every other non-root subpath in this workspace, it carries an explicit `tsconfig.base.json` `paths` entry; without one the TypeRT analyzer resolves the specifier to `lib/types/` and rejects the reference as unexported. + +`kind` is `string` on the wire rather than `TaskKind`. The kind map is merge-extensible by producer plugins, so a client build cannot enumerate the closed set; presentation falls through a documented default for an unrecognized kind. + +Three `TaskSnapshot` fields are deliberately absent: `ownerSession` (the frame's `sessionId` already carries it), `reported` (an internal notice-delivery bit with no user meaning), and `outputLimitBytes` (producer-owned model-presentation policy). + +The frame carries a whole snapshot rather than a delta for the reason [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) states for itself: start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small. + +### The task-registry change feed + +`TaskService` owns one observation method: + +```ts ignore-check +abstract onTasksChanged(listener: TasksChangedListener): () => void +``` + +It fires **after** every commit that changes what `list(owner)` returns: registration at the end of `start()`, the `stopping` transition in `kill()`, settlement, and the removal `disposeOwner()` performs. An `undefined` owner means an unowned task changed, and therefore every caller's view changed. + +The listener is owner-granular rather than task-granular. The only consumer pushes whole snapshots, so a per-task record would be discarded on arrival — and a per-task feed cannot express the owner-disposal removal at all without inventing a tombstone status nothing else needs. + +`onTaskDone` is not a subset of this. It delivers the terminal record with the exact owner `Agent` under first-wins semantics that `dsh-tool-tasks` couples to `reported`; `onTasksChanged` is pure observation with no delivery meaning and marks nothing reported. Listener throws are contained and never awaited, matching `onTaskDone`, and each registration is an effect on the calling fiber. + +Service disposal deliberately announces nothing. Every `onTasksChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set. + +### The api-proxy carrier + +`mux()` subscribes `ctx.tasks.onTasksChanged` and pushes `session/tasks`; the subscription baseline rides next to the existing `session/subscribed` control frames, so a reconnecting client is current before it renders. + +Four rules the carrier keeps: + +- **Never resume.** A change push reads `tasks.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.tasks.list(ctx.agents.get(session.id))` — the non-resuming registry read, where a session with no live Agent correctly yields only the unowned tasks. Neither path touches the [`api-remotes` Agent resolver](../../../../packages/api/remotes/src/agent-lookup.ts), which resumes a cold session as a side effect of lookup; listing must never revive a session the user merely scrolled past. +- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every subscribed session, because unowned tasks are visible to every caller. +- **Stay optional.** The carrier reads `ctx.get('tasks')`. A composition without the registry emits no frames, and the client renders no entry point — the posture `sessionProjections` already has in this file. +- **Say nothing about nothing.** The baseline is pushed only for sessions whose list is non-empty, and an absent key on the client means an empty list. A change that empties a list still pushes `[]`, because that one transition is the only thing the client cannot infer from absence. + +### The client mirror + +`SessionListState` carries `tasksBySession: Readonly>`, owned by `SessionManager` and folded from the frame under last-wins, with an emptied set stored as an absent key so absence and `[]` are one representation. + +It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel. + +Two clears keep it honest. On re-subscribe the manager drops the session's mirror — the rule `session/queue` already follows, because a fresh baseline is arriving and this generation sends none for an empty set, so a retained list would survive as a phantom. On `host/session-removed` it drops the mirror again: owner disposal already removed the records registry-side, but that lands on the mux stream while the removal frame rides the host stream, so the two have no relative order. + +### The header action + +[`@deepseek-ai/dsh-client-ui-task`](../../../../packages/client/ui-task/README.md) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. + +A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks. + +### What this deliberately does not do + +**No web path calls `ctx.tasks.read()`.** It consumes the single output cursor, so a browser read would silently take bytes the model's `task_output` will never see. This is an invariant worth a test rather than a convention, because the failure is invisible at the call site. + +**No cancellation.** That phase owes a decision the seam does not currently answer: `kill()` marks terminal delivery reported, so a human interrupt written against today's contract would leave the model believing its task is still running. + +**No output watermark on the frame.** The output phase's delta channel is where an anchor field earns its place; one added now would have no reader. + +## Alternatives considered + +**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `tasks-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window. + +**Popover-scoped polling with no seam change.** Cheapest to build and the only option that avoids touching `TaskService`. It cannot support a resident count on the trigger without a resident poll, and both later phases need a real change feed anyway, so it buys a week and spends it back. + +**A session-projection unit over durable task events.** Projection units fold over committed session events, so this would first require task lifecycle to become durable — `task/started` … `task/settled` as a standalone open/close bracket, with the last [`session/end-seed`](../../../../packages/core/session/src/types.ts) marking any unmatched opener as dead history, exactly as the compaction bracket already does. It is genuinely cheaper on the client: `dsh-tool-todo` shows the whole pattern in a fifteen-line unit, and the existing `session/projection` frames, history-tail block, and persisted checkpoint cache would have carried the data with no new wire surface, no carrier subscription, and no manager state. It was rejected because it buys that with a durable format change in service of a browser list, and because it does not extend to the phase it would most need to: [`spill/`](../../../../packages/spill/README.md) exists precisely so oversized tool output stays out of the log, so streamed task output cannot ride durable events either way. Nothing here forecloses revisiting it if durable task history becomes valuable on its own merits. + +**Reusing `PublicTaskSnapshot` from `dsh-tool-tasks`.** Nearly the right fields, but it belongs to the model-facing control surface. A wire type a browser program imports from a tool package couples client presentation to prompt-facing decisions and drags a host-only package into a client build. + +**Folding tasks into the subagent catalog as one "activity" panel.** One entry point instead of two. Rejected because `SubagentCatalogAction` is already 605 lines whose subject is a durable session-lineage tree including finished children; process-scoped tasks are a second data model with different identity, lifetime, and affordances, and the catalog's lazily-expanded branch, duration, and token contracts would all need rewriting to host them. + +**A host-global task list across every session.** The literal reading of "show all running tasks". Rejected because the registry's authorization fence is per-owner-session, so a global read needs a new access rule, and a global list has no business in a session's header — it would need its own home in the sidebar. Nothing in this design blocks adding it later; the per-session frames are the same data. + +## Testing + +The [web e2e scenario](../../../../apps/web/tests/background-task-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.tasks`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer. + +Below it, [`tasks-local`](../../../../packages/tasks/tasks-local/tests/tasks.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`api-proxy-tasks`](../../../../packages/host/apiproxy/tests/api-proxy-tasks.spec.ts) pins the baseline-only-when-non-empty rule, the three change pushes, the dropped internal fields, the unowned fan-out, the no-resume guarantee, and the registry-absent composition; and the client suites pin the last-wins fold, the absent-key representation, both clears, and the component's ordering, duration, and dismissal behavior. + +## Consequences + +**A missed commit point leaks rows.** If `disposeOwner()` removal ever stops firing the feed, the client keeps tasks that no longer exist until the session disappears. The whole-snapshot shape makes this recoverable rather than corrupting — the next legitimate change repairs the list — but the disposal path is the one most easily forgotten, so it carries its own test. + +**Unowned-task fan-out is easy to under-implement.** Pushing only to the changed owner's session is correct for owned tasks and silently wrong for unowned ones, which are visible everywhere. The bug would surface only in compositions that create unowned tasks, which is why the carrier suite covers it directly. + +**The UI set is not the registry's set.** An unowned task is invisible in the header while `task_list` still reports it to the model. In practice every tool call carries an agent, so this stays theoretical, but the two surfaces are not interchangeable. + +**Settled rows accumulate.** The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint. + +**`stopping` is nearly unreachable today.** Only the model's `task_kill` produces it, so the state is rendered but rarely seen until human cancellation lands. It is in the union now because leaving a status out would have made that phase a wire change. + +**Two entry points for one running subagent.** Accepted deliberately, and bounded to one-shot background delegations. If it reads as noise in practice, the fix is presentational — the catalog row can cite the task rather than the task list hiding the kind. + +**A new non-root subpath needs its `paths` entry.** `@deepseek-ai/dsh-tasks/brand` had to be registered in `tsconfig.base.json` before the TypeRT analyzer would accept the reference. The failure mode is a confusing "not exported by" error from a generator far from the edit, so the entry is part of adding a subpath, not an optimization. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md new file mode 100644 index 0000000000..1049218337 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md @@ -0,0 +1,136 @@ +# Agent Note: Web 后台任务展示 + +Status: implemented + +[English](2026-08-08-web-background-task-display.md) | 中文 + +## 问题 + +`ctx.tasks` 已经承载了 harness 在后台启动的全部长时工作——`bash`、`pwsh`、`pty-send`,以及一次性后台 subagent——但它唯一的读者是模型。[`dsh-tool-tasks`](../../../../packages/tasks/tool-tasks/README.md) 暴露了 `task_list`、`task_output` 和 `task_kill`,除此之外没有任何东西观察这个注册表。 + +于是 Web 端的人类看不到构建正在跑,分不清一个任务是已经完成还是卡死,也无法把它停掉。唯一的痕迹是 transcript 里更早某处那张打印了 task id 的 `run_in_background` 工具卡片,而那张卡片此后再也不会更新。 + +会话 header 本来就是每会话后台活动的落点:[`dsh-client-ui-subagent`](../../../../packages/client/ui-subagent/README.md) 把 subagent 目录贡献到 `conversation.session.header.actions`。位置没有争议。缺的是任何一条把任务状态送到浏览器的通道。 + +## 决策 + +任务状态以**每会话一帧的整份快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。 + +本次只交付列表。每个任务的流式输出与人类发起的中断是各自独立的阶段,而通道的形状让两者都不必推翻它。 + +### 线路形状 + +mux 流中的一帧: + +```ts ignore-check +| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] } +``` + +`TaskView` 是浏览器安全类型,由载体在 [`packages/host/apiproxy/src/api/tasks.ts`](../../../../packages/host/apiproxy/src/api/tasks.ts) 里拥有,与其他领域契约并列,线路 schema 就在旁边的 `tasks.schema.ts`: + +```ts +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' + +export interface TaskView { + id: TaskId + kind: string + label: string + status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + detail?: string + startedAt: number + finishedAt?: number +} +``` + +`TaskId` 取自不依赖 cordis 的 [`@deepseek-ai/dsh-tasks/brand`](../../../../packages/tasks/tasks/src/brand.ts) 叶子——与 `api/subagents.ts` 已经在用的 `@deepseek-ai/dsh-llm/brand` 导入是同一种安排,因为 `dsh-tasks` 根出口会牵到 `dsh-agent`,即便只作类型也无法被客户端程序触及。和本仓库其他每一个非根子路径一样,它带有显式的 `tsconfig.base.json` `paths` 条目;没有这一条,TypeRT 分析器会把该 specifier 解析到 `lib/types/` 并判定该引用未被导出。 + +线路上的 `kind` 是 `string` 而非 `TaskKind`。kind 映射由生产者插件按声明合并扩展,客户端构建无法枚举这个闭集;遇到无法识别的 kind,呈现层走一条有文档的默认分支。 + +`TaskSnapshot` 的三个字段被刻意省去:`ownerSession`(帧的 `sessionId` 已经带了)、`reported`(内部的通知投递位,对用户无意义),以及 `outputLimitBytes`(生产者拥有的模型呈现策略)。 + +这一帧带整份快照而非增量,理由就是 [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) 为自己写下的那条:启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。 + +### 任务注册表变更订阅 + +`TaskService` 拥有一个观察方法: + +```ts ignore-check +abstract onTasksChanged(listener: TasksChangedListener): () => void +``` + +它在每一个会改变 `list(owner)` 返回内容的提交点**之后**触发:`start()` 末尾的注册、`kill()` 里转入 `stopping`、结算,以及 `disposeOwner()` 执行的移除。`owner` 为 `undefined` 表示一个无主任务发生了变化,因而每一个调用方的视图都变了。 + +监听器按 owner 而非按任务分粒度。唯一的消费方推的是整份快照,逐任务记录到手即弃——而且逐任务的订阅根本无法表达 owner 销毁时的移除,除非发明一个别处都不需要的墓碑状态。 + +`onTaskDone` 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner `Agent`,`dsh-tool-tasks` 把这套语义与 `reported` 绑在一起;`onTasksChanged` 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 `onTaskDone` 一致,每次注册都是调用方 fiber 上的 effect。 + +服务销毁刻意什么都不通告。每个 `onTasksChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。 + +### api-proxy 载体 + +`mux()` 订阅 `ctx.tasks.onTasksChanged` 并推送 `session/tasks`;订阅 baseline 紧挨着既有的 `session/subscribed` 控制帧发出,让重连的客户端在渲染前就是最新的。 + +载体守着四条规则: + +- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `tasks.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.tasks.list(ctx.agents.get(session.id))`——不触发 resume 的注册表读法,没有活体 Agent 的会话正确地只得到无主任务。两条路径都不碰 [`api-remotes` 的 Agent 解析器](../../../../packages/api/remotes/src/agent-lookup.ts),那个解析器会把查询变成复活冷会话的副作用;列个任务不该让用户随手划过的会话活过来。 +- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已订阅会话推一份新快照,因为无主任务对所有调用方可见。 +- **保持可选。** 载体读 `ctx.get('tasks')`。没有挂注册表的组合不发任何帧,客户端也就不渲染入口——`sessionProjections` 在这个文件里已经是这个姿态。 +- **没有就不说。** baseline 只为列表非空的会话推送,客户端上键缺失即表示空列表。把列表清空的那次变更仍然推 `[]`,因为这一个转换是客户端唯一无法从「缺失」推断出来的东西。 + +### 客户端镜像 + +`SessionListState` 带有 `tasksBySession: Readonly>`,由 `SessionManager` 拥有,按 last-wins 从帧折叠而来;被清空的集合存为缺失的键,使「缺失」与 `[]` 成为同一种表示。 + +它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。 + +两处清理让它保持诚实。重新订阅时 manager 丢弃该会话的镜像——`session/queue` 已经遵循的规则,因为新的 baseline 正在路上,而这一世代对空集不发 baseline,被留下的列表会变成幽灵。`host/session-removed` 时再丢一次:owner 销毁在注册表侧已经移除了记录,但那件事落在 mux 流上而这一帧走 host 流,两者没有相对顺序。 + +### header 入口 + +[`@deepseek-ai/dsh-client-ui-task`](../../../../packages/client/ui-task/README.md) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 + +因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。 + +### 刻意不做的事 + +**没有任何 Web 路径调用 `ctx.tasks.read()`。** 它消费唯一的输出游标,浏览器读一次就悄悄拿走了模型 `task_output` 永远看不到的字节。这该是一条有测试兜底的不变量而不是一条约定,因为它的故障在调用点完全不可见。 + +**不做中断。** 那一期欠一个 seam 目前没有回答的决策:`kill()` 会把终态投递标为已上报,所以照今天的契约写出来的人类中断,会让模型一直以为它的任务还在跑。 + +**帧上不带输出水位。** 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。 + +## 备选方案 + +**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `tasks-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。 + +**只在弹层打开时轮询,不改 seam。** 最省事,也是唯一不碰 `TaskService` 的选项。它无法在不常驻轮询的前提下支持触发器上的常驻计数,而后面两期反正都需要一条真正的变更订阅,所以它省下一周又还回去。 + +**基于持久任务事件的 session-projection 单元。** 投影单元在已提交的会话事件上折叠,所以这条路要先让任务生命周期变持久——`task/started` … `task/settled` 作为一对独立的开合括号,由最后一个 [`session/end-seed`](../../../../packages/core/session/src/types.ts) 把未配对的开括号标为死历史,与 compaction 括号已有的做法完全一致。它在客户端确实更省:`dsh-tool-todo` 用十五行的单元展示了整套模式,而现成的 `session/projection` 帧、history-tail 块和持久化 checkpoint 缓存本可以承载这批数据,无需新线路面、无需载体订阅、无需 manager 状态。否决它,是因为这要拿一次持久格式变更去换一个浏览器列表,而且它并不能延伸到最需要它的那一期:[`spill/`](../../../../packages/spill/README.md) 的存在正是为了让超大工具输出留在日志之外,所以流式任务输出无论如何都不能骑在持久事件上。如果持久任务历史将来凭自身价值站得住,本设计不阻挡重新考虑它。 + +**复用 `dsh-tool-tasks` 的 `PublicTaskSnapshot`。** 字段几乎就是对的,但它属于面向模型的控制面。浏览器程序从一个 tool 包导入线路类型,会把客户端呈现耦合到面向 prompt 的决策上,并把一个 host-only 包拖进客户端构建。 + +**并进 subagent 目录做成统一的「活动」面板。** 一个入口而不是两个。否决的理由是 `SubagentCatalogAction` 已经 605 行,其主题是含已结束子会话的持久会话血缘树;进程域的任务是第二套数据模型,身份、生命期和可用动作都不同,而目录的懒展开分支、时长与 token 契约全都要重写才能容纳它们。 + +**跨全部会话的 host 全局任务列表。**「显示所有运行中任务」的字面读法。否决是因为注册表的鉴权围栏是按 owner 会话的,全局读需要一条新的访问规则,而且全局列表不该出现在某个会话的 header 里——它需要侧栏里自己的位置。本设计没有阻挡后续再加;按会话的帧就是同一批数据。 + +## 测试 + +[web e2e 场景](../../../../apps/web/tests/background-task-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.tasks`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。 + +在它之下,[`tasks-local`](../../../../packages/tasks/tasks-local/tests/tasks.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`api-proxy-tasks`](../../../../packages/host/apiproxy/tests/api-proxy-tasks.spec.ts) 钉住「非空才发 baseline」、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证,以及没有注册表的组合;客户端各套件钉住 last-wins 折叠、缺失键表示、两处清理,以及组件的排序、时长与关闭行为。 + +## 影响 + +**漏掉一个提交点会漏行。** 如果 `disposeOwner()` 的移除有朝一日不再触发订阅,客户端会一直留着已经不存在的任务,直到会话消失。整份快照的形状让这件事可恢复而非损坏——下一次正当变更就修好了——但销毁路径是最容易被忘掉的一条,所以它自带测试。 + +**无主任务的扇出很容易做漏。** 只推给变更 owner 所在的会话,对有主任务是对的,对处处可见的无主任务则是悄悄错的。这个 bug 只会在会创建无主任务的组合里显形,所以载体套件直接覆盖了它。 + +**UI 的集合不等于注册表的集合。** 无主任务在 header 里不可见,而 `task_list` 仍会把它报告给模型。实践中每次工具调用都带着 agent,所以这停留在理论层面,但两个界面并不可互换。 + +**终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。 + +**`stopping` 今天几乎不可达。** 只有模型的 `task_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。 + +**一个运行中的 subagent 有两个入口。** 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。 + +**新增非根子路径必须补 `paths` 条目。** `@deepseek-ai/dsh-tasks/brand` 得先登记进 `tsconfig.base.json`,TypeRT 分析器才会接受该引用。它的故障表现是一条来自远离改动处的生成器的、令人困惑的「not exported by」错误,所以这个条目是新增子路径的组成部分,而不是优化。 diff --git a/apps/web/tests/background-task-list.e2e.ts b/apps/web/tests/background-task-list.e2e.ts new file mode 100644 index 0000000000..217eda49fa --- /dev/null +++ b/apps/web/tests/background-task-list.e2e.ts @@ -0,0 +1,130 @@ +// Web e2e scenario: the session-header background-task list over the real +// host. No model call is involved — a genuine `run_in_background` bash call +// registers with `ctx.tasks`, and the assertion chain is the whole delivery +// path: registry change feed → api-proxy `session/tasks` frame → the client's +// `tasksBySession` mirror → the header action. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { TaskId } from '@deepseek-ai/dsh-tasks' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/background-task-list', import.meta.url)) +const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') +const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'background-task-list-web-e2e' +// Long enough that the running assertions never race the process exiting on +// their own; the test kills it explicitly to reach the settled state. +const COMMAND = 'sleep 45' + +/** + * Wait for the Host to publish the live Agent that opening a session resumes. + * @param scaffold - the booted web scaffold. + * @param sessionId - the opened session's identity. + * @returns the registered Agent instance. + */ +async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { + const deadline = Date.now() + 30_000 + for (;;) { + const found = scaffold.ctx.agents.get(sessionId) + if (found !== undefined) return found + if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +describe.skipIf(MODE === 'record')('web e2e: background task list', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + let taskId: TaskId + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + 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 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // Opening the session drives the Host's ordinary Agent resolution; the + // task owner must be that exact live instance, never a second one. + // `expect.poll` is test-scoped, so this hook polls by hand. + agent = await liveAgent(scaffold, SessionId(SEED_ID)) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('shows a running background task in the session header without a refresh', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-running')) + const trigger = page.getByRole('button', { name: '1 background task running' }) + await expect.poll(() => trigger.count(), { timeout: 5_000 }).toBe(0) + + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('background-task-list-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Hold a background slot open', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-\d+\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no task id: ${reported}`) + taskId = TaskId(matched[0]) + + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + const row = page.getByRole('list', { name: 'Background tasks' }).getByRole('listitem').first() + await row.waitFor({ timeout: 10_000 }) + await expect.poll(() => row.textContent()).toContain(COMMAND) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('flips the open list to the cancelled outcome when the registry settles it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-settled')) + expect(scaffold.ctx.tasks.kill(taskId, agent, 'web e2e cancellation')).toBe('requested') + + // The trigger drops its live count once the task leaves running/stopping, + // which is also the proof that settlement reached the browser unprompted. + const idle = page.getByRole('button', { name: '1 background task' }) + await idle.waitFor({ timeout: 20_000 }) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/background-task-list/running.expected.md b/apps/web/tests/snapshots/background-task-list/running.expected.md new file mode 100644 index 0000000000..1adaa965e2 --- /dev/null +++ b/apps/web/tests/snapshots/background-task-list/running.expected.md @@ -0,0 +1,2 @@ +- list "Background tasks": + - listitem: bash sleep 45 running {{duration}} diff --git a/apps/web/tests/snapshots/background-task-list/settled.expected.md b/apps/web/tests/snapshots/background-task-list/settled.expected.md new file mode 100644 index 0000000000..c97ff5026c --- /dev/null +++ b/apps/web/tests/snapshots/background-task-list/settled.expected.md @@ -0,0 +1,2 @@ +- list "Background tasks": + - listitem: "bash sleep 45 signal: SIGTERM {{duration}}" diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 354781e934..12202b6457 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -67,6 +67,7 @@ "tests/goal-bar.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", + "tests/background-task-list.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", "tests/turn-tail-actions.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1f81974b21..01518ad6cd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2565,6 +2565,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slash` ([`packages/client/ui-slash/src/index.ts`](../packages/client/ui-slash/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-task` ([`packages/client/ui-task/src/index.ts`](../packages/client/ui-task/src/index.ts)) - `@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)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a22c3464eb..fe64a868a6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2338,6 +2338,22 @@ abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSigna */ abstract onTaskDone(listener: TaskDoneListener): () => void +/** + * Register an effect-scoped observer of visible-set changes. It fires after + * every commit that changes what {@link list} returns for that owner — + * registration, the stopping transition, settlement, and owner-disposal + * removal — so an observer re-reads rather than accumulating deltas. + * + * This is not a superset of {@link onTaskDone}: that one delivers the terminal + * record under first-wins semantics a control surface couples to notice + * delivery, while this one carries no delivery meaning and marks nothing + * reported. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned task changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onTasksChanged(listener: TasksChangedListener): () => void + /** * Attach an effect-scoped surface that can read and stop tasks. {@link start} * refuses work while none is attached. @@ -2347,9 +2363,9 @@ abstract onTaskDone(listener: TaskDoneListener): () => void abstract attachSurface(name: string): () => void ``` -Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-data-structures/tasks.md) · [TaskId](../core-data-structures/tasks.md) · [TaskRead](../core-data-structures/tasks.md) · [TaskSnapshot](../core-data-structures/tasks.md) · [TaskStart](../core-data-structures/tasks.md) +Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-data-structures/tasks.md) · [TaskId](../core-data-structures/tasks.md) · [TaskRead](../core-data-structures/tasks.md) · [TaskSnapshot](../core-data-structures/tasks.md) · [TaskStart](../core-data-structures/tasks.md) · [TasksChangedListener](../core-data-structures/tasks.md) -Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:53`](../../packages/tasks/tasks/src/index.ts) ## `ctx.telemetry` — `Telemetry` (abstract seam) diff --git a/docs/core-data-structures/tasks.i18n.yaml b/docs/core-data-structures/tasks.i18n.yaml index 58920a53df..0e2166d994 100644 --- a/docs/core-data-structures/tasks.i18n.yaml +++ b/docs/core-data-structures/tasks.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/core-data-structures/tasks.md -tasks.md: a38055d3ef7aa18e62678f92eb5ac5ae2a09c205 -tasks.zh.md: f34d42e713c3a0c11cbf88d52e573bb100c52493 +tasks.md: e07fa4798d9d7585fd1036346f8df39068884835 +tasks.zh.md: d958be308f462db371d10240cfa369a892fe1ce0 diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index a38055d3ef..e07fa4798d 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -151,4 +151,4 @@ interface TaskRead { ## Service behavior -The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam defines atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local implementation. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the seam contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface. +The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam defines atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` and `onTasksChanged` listeners, and the `attachSurface` availability fence; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local implementation. The two listener kinds are not nested: `onTaskDone` delivers one terminal record under first-wins semantics a control surface couples to notice delivery, while `onTasksChanged` observes every visible-set change — registration, the stopping transition, settlement, and owner-disposal removal — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the seam contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface. diff --git a/docs/core-data-structures/tasks.zh.md b/docs/core-data-structures/tasks.zh.md index f34d42e713..d958be308f 100644 --- a/docs/core-data-structures/tasks.zh.md +++ b/docs/core-data-structures/tasks.zh.md @@ -151,4 +151,4 @@ interface TaskRead { ## 服务行为 -抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam 定义原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部实现。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。seam 契约见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的接口见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam 定义原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 与 `onTasksChanged` 监听器,以及 `attachSurface` 可用性防线;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部实现。两类监听器不是包含关系:`onTaskDone` 按控制面与通知投递绑定的 first-wins 语义投递唯一一条终态记录,而 `onTasksChanged` 观察每一次可见集合的变化——注册、转入 stopping、结算,以及 owner 销毁时的移除——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。seam 契约见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的接口见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 62ed35bd49..5c7b7d83a4 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -176,6 +176,10 @@ - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' + # Background tasks: the session-header list over the tasksBySession mirror. + - id: ui-task + name: '@deepseek-ai/dsh-client-ui-task' + # Goal surface: GoalBar in the input dock over the goal session projection. - id: ui-goal name: '@deepseek-ai/dsh-client-ui-goal' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 9b8e15fd66..bd5ac23b11 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-client-ui-skill": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", + "@deepseek-ai/dsh-client-ui-task": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index e65d5246a4..d1f8676a91 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: b6fa426fbe541e2b22d2bf5f19d4397361cf0899 -README.zh.md: 5a55bb8c2c31b5215fc73e75e1c4f3aca79add64 +README.md: 347a42e0671ad0ae1da6c5488f94f387e85fcf99 +README.zh.md: 596ce3910430a183a9750c94b83cb79f43ce037f diff --git a/packages/client/README.md b/packages/client/README.md index b6fa426fbe..347a42e067 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -29,6 +29,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. | | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | +| [`ui-task/`](ui-task/README.md) | Lists this session's background tasks in the conversation header. | | [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. | | [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5a55bb8c2c..596ce39104 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -29,6 +29,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 | | [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 | | [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 | +| [`ui-task/`](ui-task/README.md) | 在会话标题栏列出当前会话的后台任务。 | | [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 | | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 6f29b2dda0..b59f7805c9 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -17,6 +17,7 @@ export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, + TaskView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index c2a6668d46..52bf8c699d 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -22,6 +22,7 @@ export type { ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, + TaskView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx index 6addc8c69c..e04fa9c311 100644 --- a/packages/client/locale/tests/language-row.spec.tsx +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 1edac07d5e..438ac72f70 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: a9b604974595b1b7856f74b72d36093491ec1bd1 -README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774 +README.md: 5abe72767aa76d5690b3f287391bc8924bc62d03 +README.zh.md: 78024fa095a5bcddc50ee121eec64e887f241ba7 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index a9b6049745..5abe72767a 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -24,6 +24,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. +`SessionListState.tasksBySession` mirrors the Host's `session/tasks` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order. + `SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. ## New Session and the blank mirror diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6eee14bdbe..78024fa095 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -24,6 +24,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 +`SessionListState.tasksBySession` 按 last-wins 镜像宿主的 `session/tasks` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。 + `SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 ## New Session 与 blank 镜像 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b7226c0085..7fceebaa24 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -36,7 +36,7 @@ export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, } from './sessions/service.ts' export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts' -export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +export type { SubagentAddress, TaskView } from '@deepseek-ai/dsh-client-connection/client' export type { WorkspaceListPhase } from './workspaces/manager.ts' export type { WorkspaceListState } from './workspaces/service.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 64199c4812..5a6d7ddbfa 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -4,7 +4,7 @@ import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, - SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId, + SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. @@ -47,6 +47,8 @@ export interface SessionListSnapshot { phase: SessionListPhase error: RpcError | null subagentsByParent: Readonly> + /** Background tasks per session; an absent key is an empty set. */ + tasksBySession: Readonly> currentAddress: SubagentAddress | undefined } @@ -137,6 +139,11 @@ export class SessionManager { private readonly catalogStale = new Set() private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() + /** + * Background tasks per session, last-wins from `session/tasks`. An empty set + * is stored as an absent key, so absence and `[]` are one representation. + */ + private readonly tasksBySession = new Map() private selected: SessionId | undefined @@ -657,10 +664,23 @@ export class SessionManager { this.notifier.markDirty() return } + if (frame.type === 'session/tasks') { + // Whole-set snapshot, so last-wins with no reconciliation. The Host omits + // the baseline for an empty set, which is the same fact an emptying change + // reports as `[]` — both land as an absent key. + if (frame.tasks.length === 0) this.tasksBySession.delete(frame.sessionId) + else this.tasksBySession.set(frame.sessionId, frame.tasks) + this.notifier.markDirty() + return + } if (frame.type === 'session/subscribed') { // Rows past the host's durable baseline rode state a restart lost; drop // them so last-wins cannot pin a phantom value over recomputed truth. this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) + // Same re-baseline reasoning as the queue below: this generation sends a + // task baseline only when the set is non-empty, so a mirror kept from the + // previous generation would survive as a phantom list. + this.tasksBySession.delete(frame.sessionId) this.notifier.markDirty() // New mux-generation baseline: discard the previous queue snapshot. // The host omits session/queue when the live queue is empty, so retaining @@ -770,6 +790,11 @@ export class SessionManager { } this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone + // Owner disposal already dropped these registry-side, but that lands on + // the mux stream while this frame rides the host stream, so the two have + // no relative order. Clearing here makes a detached Activation's rows + // disappear whichever arrives first. + this.tasksBySession.delete(frame.sessionId) if (!durableSubagent) this.projectionStores.delete(frame.sessionId) // A pull already in flight was requested before this removal and can // carry the pre-removal parentAvailable:true, which would resurrect @@ -1006,6 +1031,7 @@ export class SessionManager { phase: this.listPhase, error: this.listError, subagentsByParent: Object.fromEntries(this.catalogs), + tasksBySession: Object.fromEntries(this.tasksBySession), currentAddress: current === undefined ? undefined : this.addresses.get(current), } } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 621760df02..62b02b716f 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -17,7 +17,7 @@ */ import type { Context, Fiber } from 'cordis' import type { - IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId, + IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. @@ -80,6 +80,12 @@ export interface SessionListState { phase: SessionListPhase /** Direct durable catalogs keyed by their selected parent address. */ subagentsByParent: Readonly> + /** + * Background tasks each session can see, mirrored last-wins from + * `session/tasks`. A missing key is an empty set — the Host sends no baseline + * for a session without tasks — so consumers read absence, never a sentinel. + */ + tasksBySession: Readonly> /** Current session's catalog-derived address, absent on ordinary navigation. */ currentAddress: SubagentAddress | undefined } @@ -271,7 +277,7 @@ export class SessionsService implements ISessions { this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. @@ -606,7 +612,7 @@ export class SessionsService implements ISessions { /** Project the manager's list snapshot into the store (title derivation is display-only). */ private projectList(): void { const { - items, current, phase, subagentsByParent, currentAddress, + items, current, phase, subagentsByParent, tasksBySession, currentAddress, } = this.manager.getListSnapshot() const ids: SessionId[] = [] const byId: Record = {} @@ -675,7 +681,7 @@ export class SessionsService implements ISessions { ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }), }) } - this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress }) + this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress }) this.pruneScopes() } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index e203e49dd9..c63dfbe4de 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -1110,3 +1110,60 @@ describe('completed reminder', () => { expect(entry(manager, S2)?.completed).toBe(true) }) }) + +describe('background-task mirror', () => { + const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({ + id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over, + }) + const tasksFrame = (sessionId: SessionId, tasks: unknown[]) => + ({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never }) + + it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) + const first = manager.getListSnapshot().tasksBySession + expect(first[S1]).toEqual([view()]) + expect(first[S2]?.[0]?.label).toBe('other') + + // Last-wins: the newer whole set replaces, it does not merge. + manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })])) + expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([view({ status: 'completed' })]) + }) + + it('stores an emptied set as an absent key so absence and [] read alike', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true) + manager.handleMuxEnvelope(tasksFrame(S1, [])) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false) + }) + + it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleMuxEnvelope({ + rpcId: 's' as never, + payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 }, + }) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false) + }) + + it('drops the rows when the session is removed, whichever stream lands first', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false) + }) + + it('notifies list subscribers so an open header re-renders without a poll', async () => { + const manager = new SessionManager(new FakeApiClient()) + const seen = vi.fn() + manager.subscribe(seen) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + // The notifier batches on a microtask; the frame itself is already applied. + await Promise.resolve() + expect(seen).toHaveBeenCalled() + }) +}) diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 4747929572..ee957447d3 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -192,7 +192,7 @@ export class TestSessions implements ISessions { constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) { this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) this.channel = new SessionProvideChannel({ rebuildBundles: () => { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 38d643292e..a097cc7fa9 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -110,7 +110,7 @@ const compaction = (over: Partial = {}): CompactionSummar /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx index e8d44c9bf3..573e3a97ed 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx @@ -16,7 +16,7 @@ afterEach(() => { function emptySessions() { return bindSnapshotSelector(createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })) } diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 3afe471d50..c008305799 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -100,7 +100,7 @@ describe('render branch tails', () => { const chat = createChatStore().create() chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) const emptyWorkspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, @@ -149,7 +149,7 @@ describe('render branch tails', () => { const chat = createChatStore().create() chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) const emptyWorkspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 47791a6090..b0052f9919 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -109,7 +109,7 @@ function bench(over?: BenchOptions) { useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index f197528a2b..e81a44e485 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -37,7 +37,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index c47b726b66..6971e72a2d 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -123,7 +123,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { useSession: bindSnapshotSelector(sessionStore), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 70b6d96710..3755a68cb1 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -107,7 +107,7 @@ function mount( ids: listed ? [root, SID] : [root], byId: { [root]: rootRow, ...listed && { [SID]: childRow } }, current: SID, - phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore(workspaceState(workspaceRows)) const session = createSnapshotStore(snapshot) diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index c300e423e6..a4b5d07709 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -61,6 +61,7 @@ function props( }, current: PARENT, phase: 'ready', subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested }, + tasksBySession: {}, currentAddress: undefined, } satisfies SessionListState function useSessions(select: (snapshot: SessionListState) => T): T { diff --git a/packages/client/ui-task/README.i18n.yaml b/packages/client/ui-task/README.i18n.yaml new file mode 100644 index 0000000000..45157f12e5 --- /dev/null +++ b/packages/client/ui-task/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-task/README.md +README.md: 318984808838cb7a336a4c34c95a2f27db578423 +README.zh.md: 7adeb254662fd23cf4bd1efc8618471cf74df7c0 diff --git a/packages/client/ui-task/README.md b/packages/client/ui-task/README.md new file mode 100644 index 0000000000..3189848088 --- /dev/null +++ b/packages/client/ui-task/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-client-ui-task + +English | [中文](README.zh.md) + +Web background-task feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.tasks` records this session can see. The data arrives entirely through the `tasksBySession` list mirror that [`dsh-client-runtime`](../runtime/README.md) folds from `session/tasks` frames, so this package issues no RPC and holds no state beyond popover visibility. + +The trigger renders only when the session has at least one task, so an ordinary conversation never grows a control for a capability it is not using. Its badge counts `running` plus `stopping` and is omitted at zero, leaving a session that holds only finished tasks a quiet entry point into its history rather than one advertising a count of nothing. The popover is a flat list: live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order so the host's map iteration never decides it. A row shows the producer kind, the label, a status marker, the producer's `detail` in place of the generic status word once it has one, and an elapsed duration. That duration advances once per second while the row is live and freezes at `finishedAt`; the clock runs only while an open list holds something that moves. A settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours rather than growing a day vocabulary no producer currently reaches. + +Settled rows stay visible and de-emphasized until the registry drops them at owner disposal. They are in the snapshot, a failed task's `detail` is the only place its failure is legible, and filtering them out here is work the output and cancellation phases would undo. A running one-shot background subagent therefore appears both here and in the [subagent catalog](../ui-subagent/README.md): the catalog navigates into the child's transcript, while this list is the only handle a future cancellation can attach to. + +Escape closes the list and returns focus to the trigger, as does a pointer press outside it. The last task disappearing closes the list before the control unmounts, so focus never vanishes from a removed node. Styling uses tokens only; copy goes through the package's own `task` locale namespace. The behavior is specified by the [Web background-task display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md). + +## Model Experience + +None, as this package renders host-computed registry state for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same tasks stays with [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md). + +#### KV Cache effect + +None; the package never assembles or sends provider requests. + +## Known Limitations and Deferred Work + +- **Rows are read-only** — a task's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer today: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its task is still running. +- **The list is not the registry's own set** — it shows what the owning session can see through the wire view, so an unowned task (one started without a live `Agent`) is invisible here while `task_list` still reports it to the model. diff --git a/packages/client/ui-task/README.zh.md b/packages/client/ui-task/README.zh.md new file mode 100644 index 0000000000..7adeb25466 --- /dev/null +++ b/packages/client/ui-task/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-client-ui-task + +[English](README.md) | 中文 + +Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.tasks` 记录。数据完全来自 [`dsh-client-runtime`](../runtime/README.md) 从 `session/tasks` 帧折叠出的 `tasksBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。 + +只有当会话至少有一个任务时才渲染触发器,普通对话不会因为一项未被使用的能力而长出控件。角标计数为 `running` 加 `stopping`,为零时省略,这样只剩已完成任务的会话保留一个安静的历史入口,而不是宣告一个「零」。弹层是一个扁平列表:活跃行在前按 `startedAt` 升序,随后终态行按 `finishedAt` 降序;毫秒相同的并列按启动顺序打破,宿主的 map 迭代顺序永远不参与决定。一行显示生产者 kind、label、状态标记、生产者一旦给出 `detail` 就取代通用状态词的那段文字,以及已耗时。该耗时在活跃时每秒推进,并在 `finishedAt` 冻结;只有当打开的列表里确实有会动的东西时时钟才运行。缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位,不会长出任何生产者目前都到不了的「天」词汇。 + +终态行保持可见并弱化,直到注册表在 owner 销毁时把它们丢掉。它们本就在快照里,失败任务的 `detail` 是其失败唯一可读之处,在这里过滤掉它们是输出与中断两期要推翻的工作。因此一个运行中的一次性后台 subagent 会同时出现在这里和 [subagent 目录](../ui-subagent/README.md)里:目录负责进入子会话的 transcript,而这个列表是将来中断能力唯一可能附着的句柄。 + +Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理。最后一个任务消失时先关闭列表再卸载控件,焦点因此不会从一个被移除的节点上凭空消失。样式只用 token;文案走本包自己的 `task` locale 命名空间。行为由 [Web 后台任务展示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md) 规定。 + +## Model Experience + +无,因为本包为人类渲染宿主计算出的注册表状态,不触及 prompt、消息、schema、流或工具结果。模型对同一批任务的视角仍属于 [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md)。 + +#### KV Cache effect + +无;本包从不组装或发送 provider 请求。 + +## Known Limitations and Deferred Work + +- **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。 +- **列表不等于注册表自己的集合** —— 它展示的是拥有它的会话通过线路视图能看到的东西,因此一个无主任务(在没有活体 `Agent` 时启动的任务)在这里不可见,而 `task_list` 仍会把它报告给模型。 diff --git a/packages/client/ui-task/package.json b/packages/client/ui-task/package.json new file mode 100644 index 0000000000..439bacfd6f --- /dev/null +++ b/packages/client/ui-task/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-task", + "description": "Session-header background-task list: live registry state mirrored from session/tasks frames", + "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", + "@deepseek-ai/dsh-client-ui-primitives" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "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", + "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:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-task/src/client/TaskListAction.module.css b/packages/client/ui-task/src/client/TaskListAction.module.css new file mode 100644 index 0000000000..6bf6ed2507 --- /dev/null +++ b/packages/client/ui-task/src/client/TaskListAction.module.css @@ -0,0 +1,116 @@ +.root { + position: relative; +} + +.trigger { + display: inline-flex; + align-items: center; + gap: 3px; + min-height: 28px; + padding: 3px 2px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.trigger:hover, +.trigger:focus-visible { + color: var(--dsw-alias-label-secondary); +} + +.trigger svg { + transition: transform 120ms ease; +} + +.triggerOpen { + transform: rotate(180deg); +} + +.triggerDot { + flex: none; +} + +.count { + margin: 0 5px; +} + +.menu { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 100; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1px; + width: 336px; + max-width: min(400px, calc(100vw - 32px)); + max-height: min(420px, calc(100vh - 140px)); + margin: 0; + padding: 4px; + overflow: auto; + list-style: none; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-specific-menu); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + box-shadow: var(--dsw-shadow-lv3); +} + +.row { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 32px; + padding: 6px 8px; + border-radius: 8px; + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 18px; +} + +.rowSettled { + color: var(--dsw-alias-label-tertiary); +} + +.rowDot { + flex: none; +} + +.kind { + flex: none; + padding: 0 6px; + border-radius: 5px; + background: var(--dsw-alias-fill-l2); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 18px; +} + +.label { + flex: 1; + min-width: 0; + overflow: hidden; + font-family: var(--dsw-font-mono); + white-space: nowrap; + text-overflow: ellipsis; +} + +.status, +.duration { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 18px; +} + +.duration { + font-variant-numeric: tabular-nums; +} diff --git a/packages/client/ui-task/src/client/TaskListAction.tsx b/packages/client/ui-task/src/client/TaskListAction.tsx new file mode 100644 index 0000000000..722d317aa9 --- /dev/null +++ b/packages/client/ui-task/src/client/TaskListAction.tsx @@ -0,0 +1,185 @@ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { TaskView } from '@deepseek-ai/dsh-client-runtime/client' +import { IconChevronDownOutline14, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import css from './TaskListAction.module.css' + +/** Full props for the session-header background-task action. */ +export type TaskListActionProps = + PropsRuntime<'conversation.session.header.actions'> & PropsLocale + +/** Stable empty list so a session with no tasks keeps one array identity. */ +const NO_TASKS: readonly TaskView[] = [] + +/** A task the registry still holds open, and whose duration therefore ticks. */ +function isLive(task: TaskView): boolean { + return task.status === 'running' || task.status === 'stopping' +} + +/** Closed-union exhaustiveness fence for the wire status set. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ +function assertNever(value: never): never { + throw new Error(`unhandled task status: ${JSON.stringify(value)}`) +} + +/** + * Status marker semantics. `stopping` and `killed` share the attention color: + * both mean the work ended (or is ending) on request rather than on its own. + */ +function dotState(status: TaskView['status']): StateDotState { + switch (status) { + case 'running': return 'ongoing' + case 'stopping': return 'warning' + case 'completed': return 'done' + case 'killed': return 'warning' + case 'failed': return 'error' + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +/** Human status word for the row and its accessible name. */ +function statusLabel(status: TaskView['status'], t: TranslateNS): string { + switch (status) { + case 'running': return t('status.running') + case 'stopping': return t('status.stopping') + case 'completed': return t('status.completed') + case 'killed': return t('status.killed') + case 'failed': return t('status.failed') + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +/** + * Elapsed time in at most two adjacent units. A background task that outlives + * an hour is already exceptional, so hours is the widest unit — beyond that the + * figure stays in hours rather than growing a day/month vocabulary no producer + * currently reaches. + */ +function formatDuration(elapsedMs: number, t: TranslateNS): string { + const total = Math.max(0, Math.floor(elapsedMs / 1_000)) + const seconds = total % 60 + const minutes = Math.floor(total / 60) % 60 + const hours = Math.floor(total / 3_600) + if (hours > 0) return t('duration.hours', { hours, minutes }) + if (minutes > 0) return t('duration.minutes', { minutes, seconds }) + return t('duration.seconds', { seconds }) +} + +/** + * Live rows first in start order, then settled rows newest-first. Two tasks + * that settled in the same millisecond fall back to start order, so the sort + * never depends on the host's map iteration. + */ +function ordered(tasks: readonly TaskView[]): TaskView[] { + return [...tasks].sort((left, right) => { + const liveLeft = isLive(left) + if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 + if (liveLeft) return left.startedAt - right.startedAt + const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt) + return finished !== 0 ? finished : left.startedAt - right.startedAt + }) +} + +/** + * Session-header entry point for this session's background tasks. It renders + * nothing at all until the session has at least one task, so an ordinary + * conversation never grows a control for a capability it is not using. + * @param props - runtime slot currency plus the namespace translator. + * @returns the trigger and its popover list, or null when there is nothing to show. + */ +export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProps) { + const tasks = useSessions(state => state.tasksBySession[sessionId]) ?? NO_TASKS + const [open, setOpen] = useState(false) + const [now, setNow] = useState(() => Date.now()) + const rootRef = useRef(null) + const triggerRef = useRef(null) + + const rows = useMemo(() => ordered(tasks), [tasks]) + const liveCount = useMemo(() => tasks.filter(isLive).length, [tasks]) + + useEffect(() => { + if (!open) return + const closeOutside = (event: PointerEvent): void => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) { + setOpen(false) + } + } + document.addEventListener('pointerdown', closeOutside) + return () => { document.removeEventListener('pointerdown', closeOutside) } + }, [open]) + + // The clock only runs while an open list is showing something that moves. + useEffect(() => { + if (!open || liveCount === 0) return + setNow(Date.now()) + const timer = setInterval(() => { setNow(Date.now()) }, 1_000) + return () => { clearInterval(timer) } + }, [open, liveCount]) + + // The last task disappearing removes this control; close first so focus does + // not vanish from an unmounting node. + useEffect(() => { + if (tasks.length === 0 && open) setOpen(false) + }, [tasks.length, open]) + + if (tasks.length === 0) return null + + const countKey = liveCount > 0 + ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') + : (tasks.length === 1 ? 'count.idle.one' : 'count.idle.other') + const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : tasks.length }) + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || !open) return + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } + + return ( +
+ + {open + ? ( +
    + {rows.map((task) => { + const live = isLive(task) + const elapsed = live ? now - task.startedAt : (task.finishedAt ?? task.startedAt) - task.startedAt + const duration = formatDuration(elapsed, t) + const status = statusLabel(task.status, t) + return ( +
  • + + {task.kind} + {task.label} + {task.detail ?? status} + + {duration} + +
  • + ) + })} +
+ ) + : null} +
+ ) +} diff --git a/packages/client/ui-task/src/client/index.ts b/packages/client/ui-task/src/client/index.ts new file mode 100644 index 0000000000..792b5f39d4 --- /dev/null +++ b/packages/client/ui-task/src/client/index.ts @@ -0,0 +1,40 @@ +/** + * Background-task plugin, browser half: contributes one session-header action + * that renders this session's `ctx.tasks` records. The data arrives entirely + * through the `tasksBySession` list mirror, so the plugin issues no RPC and + * holds no state of its own beyond popover visibility. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { TaskListAction } from './TaskListAction.tsx' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { en, NS, zh, type TaskKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Background-task list copy. */ + 'task': TaskKey + } +} + +export type { TaskListActionProps } from './TaskListAction.tsx' + +/** Required services for locale registration and header-slot contribution. */ +export const inject = ['sessions', 'slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the header action. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-task: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'task-list', + // After the subagent catalog: session lineage reads before process work. + order: 20, + locale: NS, + }, TaskListAction), + ) +} diff --git a/packages/client/ui-task/src/client/locales.ts b/packages/client/ui-task/src/client/locales.ts new file mode 100644 index 0000000000..8c03da56eb --- /dev/null +++ b/packages/client/ui-task/src/client/locales.ts @@ -0,0 +1,45 @@ +/** `task` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'task' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'count.live.one': '{count} 个后台任务运行中', + 'count.live.other': '{count} 个后台任务运行中', + 'count.idle.one': '{count} 个后台任务', + 'count.idle.other': '{count} 个后台任务', + 'list.aria': '后台任务', + 'status.running': '运行中', + 'status.stopping': '正在停止', + 'status.completed': '已完成', + 'status.killed': '已取消', + 'status.failed': '已失败', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分{seconds}秒', + 'duration.hours': '{hours}小时{minutes}分', + 'duration.title.live': '已运行 {duration}', + 'duration.title.done': '耗时 {duration}', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'count.live.one': '{count} background task running', + 'count.live.other': '{count} background tasks running', + 'count.idle.one': '{count} background task', + 'count.idle.other': '{count} background tasks', + 'list.aria': 'Background tasks', + 'status.running': 'running', + 'status.stopping': 'stopping', + 'status.completed': 'completed', + 'status.killed': 'cancelled', + 'status.failed': 'failed', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m', + 'duration.title.live': 'Running for {duration}', + 'duration.title.done': 'Took {duration}', +} + +/** Key domain of the `task` namespace (zh is the source of truth). */ +export type TaskKey = keyof typeof zh diff --git a/packages/client/ui-task/src/css-modules.d.ts b/packages/client/ui-task/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-task/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-task/src/index.ts b/packages/client/ui-task/src/index.ts new file mode 100644 index 0000000000..3962e1b113 --- /dev/null +++ b/packages/client/ui-task/src/index.ts @@ -0,0 +1,9 @@ +/** + * Background-task list plugin, node half. Pure UI plugin: the empty apply + * exists so the plugin appears in the host cordis.yml / Loader; the browser + * half ships via exports["./client"], discovered through the package.json + * dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this source plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-task/src/invariant.ts b/packages/client/ui-task/src/invariant.ts new file mode 100644 index 0000000000..b236094959 --- /dev/null +++ b/packages/client/ui-task/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-task`. + * @module @deepseek-ai/dsh-client-ui-task/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-task' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-task-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package is a read-only projection of the + * `tasksBySession` mirror onto one header slot entry. It emits no cordis + * events, owns no cross-plugin mutable state, and its single slot registration + * proves disposal through the HMR-safety spec. + */ +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-task/tests/browser-plugin.spec.ts b/packages/client/ui-task/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..3d6ba4cf94 --- /dev/null +++ b/packages/client/ui-task/tests/browser-plugin.spec.ts @@ -0,0 +1,89 @@ +/** + * ui-task plugin halves: the browser entry's dictionary and header-slot + * registrations against the real SlotsService (with fiber teardown proving + * removal — HMR safety), the inert node entry, and the invariant companion's + * ownership reservation. + */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import * as TaskInvariant from '../src/invariant.ts' +import { en, NS, zh } from '../src/client/locales.ts' + +/** Slot ledger reader: entry ids currently registered in the header list. */ +function headerEntryIds(ctx: Context): (string | undefined)[] { + return ctx.slots + .entries('conversation.session.header.actions') + .map(entry => entry.options.id) +} + +/** Boot the browser half over a real slot tree that declares the header list. */ +async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + ctx.slots.register({ + name: 'root', + children: { + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + } as never, () => null) + ctx.provide('sessions', {}) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { ctx, fiber } +} + +describe('ui-task browser half', () => { + it('declares the services it binds', () => { + expect(inject).toEqual(['sessions', 'slots', 'locale']) + }) + + it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { + const { ctx, fiber } = await bench() + expect(headerEntryIds(ctx)).toContain('task-list') + await fiber.dispose() + expect(headerEntryIds(ctx)).not.toContain('task-list') + }) + + it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { + const { ctx, fiber } = await bench() + const translate = ctx.locale.bind(NS) + expect(translate('list.aria')).toBe(zh['list.aria']) + ctx.locale.setLocale('en') + expect(translate('list.aria')).toBe(en['list.aria']) + + // Withdrawn dictionaries leave the key unresolved rather than translated. + await fiber.dispose() + expect(translate('list.aria')).not.toBe(en['list.aria']) + }) + + it('keeps the English dictionary key-identical to the Chinese source of truth', () => { + expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) + }) +}) + +describe('ui-task node half', () => { + it('contributes no host behavior', () => { + // The node half exists only so the plugin appears in the Loader tree. + expect(applyNode).not.toThrow() + }) +}) + +describe('ui-task invariant companion', () => { + it('reserves package ownership under its declared companion name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + const fiber = ctx.plugin(TaskInvariant) + await fiber.await() + expect(TaskInvariant.name).toBe('client-ui-task-invariant') + expect(TaskInvariant.inject).toEqual(['invariants']) + // Emitting an unrelated event proves the companion installed no audit. + expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow() + await fiber.dispose() + }) +}) diff --git a/packages/client/ui-task/tests/task-list-action.spec.tsx b/packages/client/ui-task/tests/task-list-action.spec.tsx new file mode 100644 index 0000000000..0d03af3ec0 --- /dev/null +++ b/packages/client/ui-task/tests/task-list-action.spec.tsx @@ -0,0 +1,239 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId, SessionListState, TaskView } from '@deepseek-ai/dsh-client-runtime/client' +import { TaskListAction, type TaskListActionProps } from '../src/client/TaskListAction.tsx' +import { zh } from '../src/client/locales.ts' + +// Live rows render `now - startedAt`, so every assertion needs a pinned clock. +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(START) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +const SESSION = 'session' as SessionId +const START = 1_700_000_000_000 +const t: TaskListActionProps['t'] = makeTranslate(zh) + +function task(over: Partial = {}): TaskView { + return { + id: 'bash-1' as TaskView['id'], + kind: 'bash', + label: 'pnpm run build', + status: 'running', + startedAt: START, + ...over, + } +} + +function props(tasks: readonly TaskView[] | undefined): TaskListActionProps { + const state = { + ids: [SESSION], + byId: {}, + current: SESSION, + phase: 'ready', + subagentsByParent: {}, + tasksBySession: tasks === undefined ? {} : { [SESSION]: tasks }, + currentAddress: undefined, + } satisfies SessionListState + function useSessions(select: (snapshot: SessionListState) => T): T { + return select(state) + } + return { sessionId: SESSION, useSessions, t } as unknown as TaskListActionProps +} + +/** + * Rows in render order as `[kind, label, status, duration]`. Adjacent spans + * carry no whitespace between them, so the cells are read one element at a + * time rather than split out of a flattened string. + */ +function rowCells(): string[][] { + return within(screen.getByRole('list', { name: zh['list.aria'] })) + .getAllByRole('listitem') + .map(row => [...row.children] + .map(cell => cell.textContent ?? '') + .filter(text => text !== '')) +} + +describe('TaskListAction visibility', () => { + it('renders nothing while the session has no tasks', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('counts only live tasks, and falls back to the total when none are live', () => { + const { rerender } = render() + expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() + + rerender() + expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() + }) + + it('closes and unmounts when the last task disappears while the list is open', () => { + const { container, rerender } = render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() + + rerender() + expect(container.innerHTML).toBe('') + }) +}) + +describe('TaskListAction rows', () => { + it('orders live tasks by start, then settled tasks newest-first', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells()).toEqual([ + ['bash', 'earlier live', '运行中', '0秒'], + ['bash', 'later live', '运行中', '0秒'], + ['bash', 'new done', '已失败', '9秒'], + ['bash', 'old done', '已完成', '1秒'], + ]) + }) + + it('breaks a settled tie on start order so map iteration never decides it', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second']) + }) + + it('prefers the producer detail over the generic status word', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells()[0]).toContain('signal: SIGTERM') + }) + + it('renders every status word, including the stopping transition', () => { + render() + fireEvent.click(screen.getByRole('button')) + const words = rowCells().map(cells => cells[2]) + expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败'])) + }) +}) + +describe('TaskListAction duration', () => { + it('advances a live row once per second and freezes a settled one', () => { + vi.setSystemTime(START + 1_000) + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells()[0]).toContain('1秒') + expect(rowCells()[1]).toContain('4秒') + + act(() => { vi.advanceTimersByTime(2_000) }) + expect(rowCells()[0]).toContain('3秒') + expect(rowCells()[1]).toContain('4秒') + }) + + it('widens to minutes and then hours, and never shows a negative figure', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒']) + }) + + it('runs no clock while the list is closed', () => { + const interval = vi.spyOn(globalThis, 'setInterval') + render() + expect(interval).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button')) + expect(interval).toHaveBeenCalledTimes(1) + }) + + it('runs no clock for an open list holding only settled tasks', () => { + const interval = vi.spyOn(globalThis, 'setInterval') + render() + fireEvent.click(screen.getByRole('button')) + expect(interval).not.toHaveBeenCalled() + }) +}) + +describe('TaskListAction dismissal', () => { + it('closes on Escape and returns focus to the trigger', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(trigger) + }) + + it('ignores other keys and a closed-list Escape', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(trigger) + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + }) + + it('closes on an outside pointer press but not on one inside', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.click(trigger) + + fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] })) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + + fireEvent.pointerDown(document.body) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) +}) + +describe('TaskListAction wire tolerance', () => { + it('treats a settled task with no finishedAt as zero-duration and sorts it by start', () => { + // `finishedAt` is optional on the wire; the Host always sets it, so this + // covers a producer or carrier that ever stops doing so. + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([ + ['finished', '3秒'], + ['no finish', '0秒'], + ]) + }) + + it('falls back to start order when neither settled task carries a finish time', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier']) + }) +}) diff --git a/packages/client/ui-task/tsconfig.json b/packages/client/ui-task/tsconfig.json new file mode 100644 index 0000000000..76ff7ad167 --- /dev/null +++ b/packages/client/ui-task/tsconfig.json @@ -0,0 +1,33 @@ +{ + "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": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-task/tsdown.config.ts b/packages/client/ui-task/tsdown.config.ts new file mode 100644 index 0000000000..2be71f65dc --- /dev/null +++ b/packages/client/ui-task/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-task', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx index 7b1e11a69b..a6aa481879 100644 --- a/packages/client/ui-theme/tests/appearance-row.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -22,7 +22,7 @@ const COPY: Record = { /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 2f5efae420..00150efdcc 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -101,7 +101,7 @@ async function bench(snapshot: ConversationSnapshot) { ids: [SID], byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, current: SID, - phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) } const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } diff --git a/packages/client/ui-tool/tests/coverage-tails.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.spec.tsx index ab1a0a9632..8010fb8763 100644 --- a/packages/client/ui-tool/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.spec.tsx @@ -30,7 +30,7 @@ function listStore() { }, current: undefined, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) } diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 7713bedaec..7239e10653 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -157,7 +157,7 @@ describe('FileMutationRow diff card', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) @@ -309,13 +309,13 @@ describe('DetailsPanel diff Output section', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index 739a787a03..25bf699529 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -171,7 +171,7 @@ describe('ReadRow keyed toolview', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) @@ -255,13 +255,13 @@ describe('DetailsPanel Output section (read)', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index 51cab2d440..98bd10131d 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -378,7 +378,7 @@ describe('DetailsPanel Output section (search)', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index c9e2875a34..722dc9b66c 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -346,7 +346,7 @@ describe('BashRow terminal card', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) @@ -446,13 +446,13 @@ describe('DetailsPanel Output section', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ @@ -643,7 +643,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }))} useWorkspaces={bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 7856ae9d6f..4ea8b713b2 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -208,7 +208,7 @@ describe('DetailsPanel web Output section', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 2a0ec68ec0..d8dfbd24f6 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -122,7 +122,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) { /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 647e29f6d3..5a6d145e36 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -17,7 +17,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), byId: Object.fromEntries(items.map(item => [item.id, item])), current: undefined, - phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ workspaceId: wid(id), path: `/projects/${id}`, title, diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 6c46275894..918a3f6c0e 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -29,7 +29,7 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial [item.id, item])), current: undefined, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, ...overrides, }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index e7788a3f63..132b64f4b2 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -28,7 +28,7 @@ function hook(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } } const sessions: SessionListState = { - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index dec95f3b5f..a138dc89ff 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1030,6 +1030,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void', jsDoc: '/**\n * Register an effect-scoped completion listener. Each listener is contained;\n * returned promises are observed but not awaited. No listener runs after\n * service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */', }, + { + signature: 'abstract onTasksChanged(listener: TasksChangedListener): () => void', + jsDoc: '/**\n * Register an effect-scoped observer of visible-set changes. It fires after\n * every commit that changes what {@link list} returns for that owner —\n * registration, the stopping transition, settlement, and owner-disposal\n * removal — so an observer re-reads rather than accumulating deltas.\n *\n * This is not a superset of {@link onTaskDone}: that one delivers the terminal\n * record under first-wins semantics a control surface couples to notice\n * delivery, while this one carries no delivery meaning and marks nothing\n * reported. Listeners are contained and never awaited.\n * @param listener - receives the owner whose visible set changed, or\n * `undefined` when an unowned task changed and every caller\'s set did.\n * @returns disposer that unregisters the listener.\n */', + }, { signature: 'abstract attachSurface(name: string): () => void', jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. {@link start}\n * refuses work while none is attached.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', @@ -2915,6 +2919,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TaskRead', declaration: 'export interface TaskRead {\n text: string;\n snapshot: TaskSnapshot;\n}', }, + { + name: 'TasksChangedListener', + declaration: 'export type TasksChangedListener = (owner: Agent | undefined) => void;', + }, { name: 'TaskSnapshot', declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: TaskKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 627f624235..73f531e34c 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: a3c9f214690144ec0f39a8690e4fd346f5e315e2 -README.zh.md: aeaf1b29e5a71674c9feedb30b67f9ce11c47340 +README.md: edf51dd1c15c61a03102003eb921a0e21b1f0fa8 +README.zh.md: 6b8bfa0459167b87c5b2848eb683cabf7ce91444 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index a3c9f21469..edf51dd1c1 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,6 +36,8 @@ Session model routing is a session-domain contract. `session.models` returns the Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. +Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames. + Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index aeaf1b29e5..6b8bfa0459 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,6 +36,8 @@ 待处理的 queued 输入属于实时控制平面契约,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 +后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 + Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 91d03b4448..30153e27a0 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index cfcae423bc..c51b8b58e8 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -30,7 +30,7 @@ import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, - QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, + QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' import { @@ -40,6 +40,9 @@ import { } from './api/session-search.ts' // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. import type {} from '@deepseek-ai/dsh-session-projection' +// Type-only: resolves `ctx.get('tasks')` to the background task registry. +import type {} from '@deepseek-ai/dsh-tasks' +import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks' // Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column). import type {} from '@deepseek-ai/dsh-session-projection-cache' // GoalError narrows domain rejections to their stable codes at the wire boundary. @@ -261,6 +264,22 @@ function subscribeSession(queue: FrameQueue>, session: Sess queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) } +/** + * Project registry snapshots onto the wire view, dropping the three internal + * fields {@link TaskView} documents as absent. + */ +function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] { + return snapshots.map(task => ({ + id: task.id, + kind: task.kind, + label: task.label, + status: task.status, + ...task.detail === undefined ? {} : { detail: task.detail }, + startedAt: task.startedAt, + ...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt }, + })) +} + /** * Whether the session's conversation has started: no turn has run yet (a * turn is one model-loop execution). Standalone plugin events — command @@ -2586,6 +2605,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) })) } } + // Background-task baseline. `ctx.agents.get` is the non-resuming read: + // a session with no live Agent owns no tasks, so it correctly sees only + // the unowned ones, and listing never revives a cold session. An empty + // set sends nothing — absence is how the client reads "no tasks". + const tasks = ctx.get('tasks') + if (tasks !== undefined) { + for (const session of ctx.sessions.list()) { + const views = taskViews(tasks.list(ctx.agents.get(session.id))) + if (views.length > 0) { + queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views })) + } + } + } // Per-session open-call table for result-view pairing. Bounded by the // per-turn call count: entries clear on turn/end; a table miss (stream // opened mid-turn) backscans the session's in-memory events instead. @@ -2614,6 +2646,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro ctx.on('session/disposed', (session: Session) => { openCalls.delete(session.id) }), + ...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => { + if (owner !== undefined) { + // The exact owner instance the fence compares against, so the + // push stays correct even while that Agent's scope is tearing + // down and a lookup by id would already miss. + queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) })) + return + } + // An unowned task is visible to every caller, so every subscribed + // session's set changed with it. + for (const session of ctx.sessions.list()) { + queue.push(frame({ + type: 'session/tasks', + sessionId: session.id, + tasks: taskViews(tasks.list(ctx.agents.get(session.id))), + })) + } + })], ] return queue.iterate(signal, () => { muxQueues.delete(queue) diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 13ead7d08d..577a40d293 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -13,6 +13,7 @@ import { approvalRequestIdSchema } from './approvals.schema.ts' import { contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema, } from './sessions.schema.ts' +import { taskViewSchema } from './tasks.schema.ts' import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' /** Question shape validated strictly against core dsh-user-interaction. */ @@ -58,6 +59,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ message: messageSchema, })), }), + z.object({ type: z.literal('session/tasks'), sessionId: sessionIdSchema, tasks: z.array(taskViewSchema) }), // value stays wide: it already passed its unit's own schema on the host, // and deep-validating here would import every domain's schema into the carrier. z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 7fc54f6f01..ac8ef6638d 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -14,6 +14,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' import type { RpcError, RpcId, RpcRequest } from './rpc.ts' +import type { TaskView } from './tasks.ts' import type { WorkspaceView } from './workspace.ts' // Client-side consumers take the render-intent vocabulary from the contract; @@ -81,6 +82,20 @@ export type MuxFrame = * in QueueDock, while pending steering renders at the conversation tail. */ | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] } + /** + * Complete set of background tasks this session can see, after every registry + * commit that changes it: registration, the stopping transition, settlement, + * and owner-disposal removal. The registry is process-local and holds no + * durable event, so — exactly like `session/queue` — the whole snapshot is + * what makes a start, a kill, a reconnect, and a second tab converge on one + * authoritative value. + * + * Sent as a subscription baseline only for a session that currently has + * tasks; an absent key means an empty set. A change that empties the set + * still sends `[]`, since that transition is the only one absence cannot + * express. + */ + | { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] } /** * One projection unit's finished value changed (session-projection RFC). * Live push state, never logged — replay recomputes on the host (the diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index cb83c5328d..ff8b4fa6ad 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -44,6 +44,7 @@ export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi, } from './subagents.ts' +export type { TaskView } from './tasks.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' diff --git a/packages/host/apiproxy/src/api/tasks.schema.ts b/packages/host/apiproxy/src/api/tasks.schema.ts new file mode 100644 index 0000000000..263b895b45 --- /dev/null +++ b/packages/host/apiproxy/src/api/tasks.schema.ts @@ -0,0 +1,33 @@ +/** + * tasks domain zod schemas: the branded task id and the wire view carried by + * `session/tasks` frames. + */ + +import { z } from 'zod' +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' +import type { TaskView } from './tasks.ts' +import type { Wire } from './rpc.schema.ts' + +/** TaskId: one brand cast after non-empty string validation. */ +export const taskIdSchema = z.string().min(1) as unknown as z.ZodType + +/** + * One wire task view. `kind` stays an open string because producer plugins + * extend the registry's kind map by declaration merging, so the closed set is + * not knowable at this boundary. + */ +export const taskViewSchema = z.object({ + id: taskIdSchema, + kind: z.string().min(1), + label: z.string().min(1), + status: z.union([ + z.literal('running'), + z.literal('stopping'), + z.literal('completed'), + z.literal('killed'), + z.literal('failed'), + ]), + detail: z.string().optional(), + startedAt: z.number().int().nonnegative(), + finishedAt: z.number().int().nonnegative().optional(), +}) satisfies z.ZodType> diff --git a/packages/host/apiproxy/src/api/tasks.ts b/packages/host/apiproxy/src/api/tasks.ts new file mode 100644 index 0000000000..f327b7cc1d --- /dev/null +++ b/packages/host/apiproxy/src/api/tasks.ts @@ -0,0 +1,36 @@ +/** + * Browser-safe background-task domain contract. The registry's live records + * never cross the wire; a view is the subset a human list needs, minted fresh + * per push. + */ + +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' + +/** + * One background task as the client sees it. + * + * Three registry fields are deliberately absent. `ownerSession` is redundant + * beside the frame's own `sessionId`; `reported` is an internal notice-delivery + * bit with no user meaning; `outputLimitBytes` is producer-owned model + * presentation policy that never reaches a human surface. + */ +export interface TaskView { + /** Registry-issued `-N` identity, stable for the task's whole life. */ + id: TaskId + /** + * Producer kind (`bash`, `pwsh`, `pty-send`, `subagent`, …). Kept as a bare + * string because producer plugins extend the kind map by declaration merging, + * so no client build can enumerate the closed set. + */ + kind: string + /** Producer-supplied one-line label: the command, or the delegation description. */ + label: string + /** Current lifecycle state. */ + status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + /** Kind-specific status detail ('exit code: 3'), present once the producer supplied one. */ + detail?: string + /** Epoch ms when the task was registered. */ + startedAt: number + /** Epoch ms when the task settled; absent while live. */ + finishedAt?: number +} diff --git a/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts new file mode 100644 index 0000000000..deabe7741a --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts @@ -0,0 +1,206 @@ +/** + * Background-task carrier paths of the host ApiProxy: the subscription + * baseline is sent only for a session that has tasks, every registry change + * pushes that owner's whole set, an unowned change fans out to every + * subscribed session, the projection drops the three internal snapshot + * fields, a composition without `ctx.tasks` emits nothing, and listing never + * resumes a cold session. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' +import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +type TaskFrame = Extract + +/** + * A producer whose settlement the test drives. `cancel` deliberately does not + * settle, so a kill is observable as the distinct `stopping` step before the + * test supplies the terminal outcome and its detail. + */ +function producer(label = 'sleep 60') { + let settle!: (outcome: TaskOutcome) => void + const spec = { + kind: 'bash' as const, + label, + run: () => ({ + cancel: () => {}, + done: new Promise((resolve) => { settle = resolve }), + }), + } + return { spec, settle: (outcome: TaskOutcome) => { settle(outcome) } } +} + +async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + if (withRegistry) { + await ctx.plugin(LocalTaskService) + ctx.tasks.attachSurface('api-proxy-test') + } + const session = ctx.sessions.create() + const agent = { + id: session.id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx, + } as Agent + ctx.agents.register(agent) + return { ctx, session, agent } +} + +const api = (ctx: Context) => createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + +/** Drain the mux until `count` session/tasks frames arrived, then abort. */ +async function collect( + iterable: AsyncIterable>, + count: number, + abort: AbortController, +): Promise { + const frames: MuxFrame[] = [] + for await (const envelope of iterable) { + frames.push(envelope.payload) + if (frames.filter(frame => frame.type === 'session/tasks').length >= count) abort.abort() + } + return frames.filter((frame): frame is TaskFrame => frame.type === 'session/tasks') +} + +describe('session/tasks subscription baseline', () => { + it('is omitted for a session with no tasks — absence is the empty set', async () => { + const { ctx, session } = await harness(true) + const abort = new AbortController() + const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-empty'), payload: {} }, abort.signal) + const frames: MuxFrame[] = [] + const drained = (async () => { + for await (const envelope of stream) { + frames.push(envelope.payload) + if (frames.some(frame => frame.type === 'session/subscribed')) abort.abort() + } + })() + await drained + expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false) + expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true) + void session + }) + + it('carries the live set for a session that already has tasks when the stream opens', async () => { + const { ctx, session, agent } = await harness(true) + ctx.tasks.start({ ...producer('pnpm run build').spec, owner: agent }) + const abort = new AbortController() + const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal) + const [baseline] = await collect(stream, 1, abort) + expect(baseline?.sessionId).toBe(session.id) + expect(baseline?.tasks).toHaveLength(1) + const [task] = baseline?.tasks ?? [] + expect(task?.startedAt).toBeTypeOf('number') + expect({ ...task, startedAt: 0 }).toEqual({ + id: 'bash-1', + kind: 'bash', + label: 'pnpm run build', + status: 'running', + startedAt: 0, + }) + }) +}) + +describe('session/tasks change pushes', () => { + it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => { + const { ctx, session, agent } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-changes'), payload: {} }, abort.signal) + const collected = collect(stream, 3, abort) + + const p = producer() + const id = ctx.tasks.start({ ...p.spec, owner: agent }) + ctx.tasks.kill(id, agent, 'test') + p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + + const frames = await collected + expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id]) + expect(frames.map(frame => frame.tasks[0]?.status)).toEqual(['running', 'stopping', 'killed']) + // Terminal detail rides the same whole-set push; no separate signal. + expect(frames[2]?.tasks[0]?.detail).toBe('signal: SIGTERM') + expect(frames[2]?.tasks[0]?.finishedAt).toBeTypeOf('number') + }) + + it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => { + const { ctx, agent } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal) + const collected = collect(stream, 1, abort) + ctx.tasks.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 }) + + const [frame] = await collected + const fields: readonly string[] = Object.keys(frame?.tasks[0] ?? {}) + expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status']) + }) + + it('fans an unowned change out to every subscribed session', async () => { + const { ctx } = await harness(true) + const second = ctx.sessions.create() + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal) + const collected = collect(stream, 2, abort) + + ctx.tasks.start(producer('open to every caller').spec) + + const frames = await collected + expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2) + expect(frames.some(frame => frame.sessionId === second.id)).toBe(true) + for (const frame of frames) expect(frame.tasks[0]?.label).toBe('open to every caller') + }) + + it('serves a cold session the unowned set without resuming it', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('session-cold-tasks') + let loaded = false + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + load: () => { loaded = true; throw new Error('task listing must not load a cold log') }, + } as never) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal) + const collected = collect(stream, 1, abort) + + ctx.tasks.start(producer().spec) + await collected + expect(loaded).toBe(false) + expect(ctx.agents.get(coldId)).toBeUndefined() + }) +}) + +describe('session/tasks without the registry', () => { + it('emits no frames at all, so the client renders no entry point', async () => { + const { ctx, session } = await harness(false) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-absent'), payload: {} }, abort.signal) + const frames: MuxFrame[] = [] + const drained = (async () => { + for await (const envelope of stream) { + frames.push(envelope.payload) + if (frames.filter(frame => frame.type === 'session/event').length >= 1) abort.abort() + } + })() + session.append('turn/start', { turn: 1 }) + await drained + expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false) + }) +}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 75b6dff3f7..50a3b164f9 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -433,6 +433,11 @@ describe('events frame schemas', () => { }, ] }, { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, + { type: 'session/tasks', sessionId: 's', tasks: [] }, + { type: 'session/tasks', sessionId: 's', tasks: [ + { id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 }, + { id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 }, + ] }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) @@ -441,6 +446,14 @@ describe('events frame schemas', () => { { type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 }, { type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 }, { type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 }, + // A producer kind stays an open string, but the closed status set and + // the identity/label bounds are the carrier's own wire contract. + { type: 'session/tasks', sessionId: 's', tasks: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] }, ]) expect(() => muxFrameSchema.parse(invalid)).toThrow() expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q') }) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 8686a9e468..a617f5b804 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -65,6 +65,9 @@ { "path": "../../skill/skill" }, + { + "path": "../../tasks/tasks" + }, { "path": "../../ui/commands" }, diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index f108022b17..0e0d3479b0 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -13,7 +13,10 @@ import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks' -import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks' +import type { + TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus, + TasksChangedListener, +} from '@deepseek-ai/dsh-tasks' /** Timeout code that distinguishes a bounded wait from caller cancellation. */ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' @@ -59,6 +62,7 @@ export class LocalTaskService extends TaskService { private counters = new Map() private surfaces = new Set() private listeners = new Set() + private changeListeners = new Set() private listenersClosed = false /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ private ownerCleanups = new Map Promise | void>() @@ -119,6 +123,9 @@ export class LocalTaskService extends TaskService { this.settle(task, { status: 'failed', detail: String(error) }) }, ) + // Registration is complete and cannot fail from here, so the visible set + // has genuinely changed. + this.notifyChanged(task.owner) return id } @@ -156,6 +163,7 @@ export class LocalTaskService extends TaskService { task.cancel(reason) task.status = 'stopping' task.reported = true + this.notifyChanged(task.owner) return 'requested' } @@ -217,6 +225,14 @@ export class LocalTaskService extends TaskService { return () => void dispose() } + onTasksChanged(listener: TasksChangedListener): () => void { + const dispose = this.ctx.effect(() => { + this.changeListeners.add(listener) + return () => this.changeListeners.delete(listener) + }, 'tasks.onTasksChanged()') + return () => void dispose() + } + attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) @@ -262,6 +278,20 @@ export class LocalTaskService extends TaskService { } } + /** + * Announce that one owner's visible set changed. Each listener is contained + * so an observer cannot break a lifecycle commit that already happened. + */ + private notifyChanged(owner: Agent | undefined): void { + for (const listener of this.changeListeners) { + try { + listener(owner) + } catch (error: unknown) { + this.selfCtx.logger.warn(`tasks: onTasksChanged listener threw: ${String(error)}`) + } + } + } + /** * Record the first terminal outcome, notify contained listeners, and release * waiters. First-wins preserves a teardown force-failure against late producer @@ -291,6 +321,7 @@ export class LocalTaskService extends TaskService { task.waitResolvers.clear() for (const resolveWait of waitResolvers) resolveWait() task.markSettled() + this.notifyChanged(task.owner) } /** @@ -323,6 +354,9 @@ export class LocalTaskService extends TaskService { this.cancelForTeardown(owned, 'owner disposed') await Promise.all(owned.map(task => task.settled)) for (const task of owned) this.store.delete(task.id) + // Removal is the one visible-set change no per-task record carries, so it + // must be announced here or an observer keeps the dropped rows forever. + if (owned.length > 0) this.notifyChanged(owner) } /** @@ -336,6 +370,11 @@ export class LocalTaskService extends TaskService { this.cancelForTeardown(all, 'tasks service disposed') await Promise.all(all.map(task => task.settled)) this.store.clear() + // No change notification here: every `onTasksChanged` registration is an + // effect on this service's own fiber, so the listeners are already gone by + // the time service teardown reaches this line. An observer learns the + // registry left through its own disposal, not through a final empty set. + this.changeListeners.clear() // Detach cross-fiber owner effects after the shared store is quiescent. const ownerCleanups = [...this.ownerCleanups.values()] this.ownerCleanups.clear() diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 29d859760f..ebd1e73b52 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -760,3 +760,96 @@ describe('LocalTaskService disposal', () => { expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached') }) }) + +describe('LocalTaskService.onTasksChanged', () => { + it('fires after registration, the stopping transition, and settlement', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'alice') + ctx.agents.register(owner) + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + + const p = producer({ owner }) + const id = ctx.tasks.start(p.spec) + // Registration is announced only once the record is readable. + expect(seen).toEqual(['alice']) + expect(ctx.tasks.list(owner)).toHaveLength(1) + + expect(ctx.tasks.kill(id, owner)).toBe('requested') + expect(seen).toEqual(['alice', 'alice']) + expect(ctx.tasks.get(id, owner).status).toBe('stopping') + + p.settle({ status: 'killed' }) + await tick() + expect(seen).toEqual(['alice', 'alice', 'alice']) + expect(ctx.tasks.get(id, owner).status).toBe('killed') + await disposeAgentScope(owner) + }) + + it('reports an unowned change as undefined, since every caller can see it', async () => { + const ctx = await harness() + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + + ctx.tasks.start(producer().spec) + expect(seen).toEqual([undefined]) + }) + + it('announces the owner-disposal removal, and stays silent when that owner had none', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'alice') + const bystander = stubAgent(ctx, 'bob') + ctx.agents.register(owner) + ctx.agents.register(bystander) + const p = producer({ owner }) + ctx.tasks.start(p.spec) + + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + p.settle({ status: 'completed' }) + await tick() + expect(seen).toEqual(['alice']) + + // Disposing an owner with no records changes no visible set. + await disposeAgentScope(bystander) + expect(seen).toEqual(['alice']) + + await disposeAgentScope(owner) + expect(seen).toEqual(['alice', 'alice']) + expect(ctx.tasks.list(owner)).toEqual([]) + }) + + it('contains a throwing listener so the lifecycle commit still stands', async () => { + const ctx = await harness() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(() => { throw new Error('observer boom') }) + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + + const id = ctx.tasks.start(producer().spec) + expect(id).toBe('bash-1') + expect(seen).toEqual([undefined]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onTasksChanged listener threw')) + }) + + it('unregisters through its disposer and with its fiber (HMR safety)', async () => { + const ctx = await harness() + const seen: number[] = [] + const detach = ctx.tasks.onTasksChanged(() => void seen.push(1)) + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.tasks.onTasksChanged(() => void seen.push(2)) + }, { inject: ['tasks'] })) + + ctx.tasks.start(producer().spec) + expect(seen).toEqual([1, 2]) + + detach() + detach() // second call of the same disposer is a no-op + ctx.tasks.start(producer().spec) + expect(seen).toEqual([1, 2, 2]) + + await fiber.dispose() + ctx.tasks.start(producer().spec) + expect(seen).toEqual([1, 2, 2]) + }) +}) diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 7ceb8ff432..3167c00cf4 100644 --- a/packages/tasks/tasks/README.i18n.yaml +++ b/packages/tasks/tasks/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/tasks/tasks/README.md -README.md: 2f822bad139020f0ebae0165aa4e8893853f635d -README.zh.md: fdc619fbb46267b2ae550c85cb14fcf8a916f638 +README.md: e7431cca0956429788274b0d44a249dd1e374156 +README.zh.md: e8c1162f66471eee3c04111c8617dd84295dc6cf diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 2f822bad13..e7431cca09 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -12,6 +12,7 @@ The background task registry seam (`ctx.tasks`). The abstract `TaskService` and - `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported. - `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter. - `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited. +- `onTasksChanged(listener)` observes visible-set changes — registration, the stopping transition, settlement, and owner-disposal removal — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. It is owner-granular because removal is a change no per-task record can express, and it is not a superset of `onTaskDone`: it carries no delivery meaning and marks nothing reported. - `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached. Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal. diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index fdc619fbb4..e8c1162f66 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -12,6 +12,7 @@ - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 - `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 - `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。 +- `onTasksChanged(listener)` 观察可见集合的变化——注册、转入 stopping、结算,以及 owner 销毁时的移除——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。 - `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。 有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。 diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index eea8e2af6b..d20d16d7c9 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.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/tasks/tasks/src/brand.ts b/packages/tasks/tasks/src/brand.ts new file mode 100644 index 0000000000..b9596514b3 --- /dev/null +++ b/packages/tasks/tasks/src/brand.ts @@ -0,0 +1,28 @@ +/** + * dsh-tasks' owned branded id, carried across the registry, the model-facing + * control surface, and the client wire. + * + * It lives in its own leaf because the package root and `./types` both reach + * `dsh-agent` through the owner and listener signatures, which a Client program + * cannot resolve even as a type. A browser-safe consumer imports the id here; + * `Branded` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`. + * + * @module @deepseek-ai/dsh-tasks/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** + * Identifies a background task. The registry generates `-N`; predictable + * ids rely on owner authorization rather than secrecy. + */ +export type TaskId = Branded<'TaskId'> + +/** + * Brand a string as a {@link TaskId}. + * @param id - the raw task-id string (the registry generates `-N`). + * @returns the same string, branded; no validation is performed. + */ +export function TaskId(id: string): TaskId { + return id as TaskId +} diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index e237aa0681..27a07d1da3 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -8,7 +8,9 @@ import { Context, Service } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart } from './types.ts' +import type { + TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart, TasksChangedListener, +} from './types.ts' export { TaskId } from './types.ts' export type { @@ -21,6 +23,7 @@ export type { TaskSnapshot, TaskStart, TaskStatus, + TasksChangedListener, } from './types.ts' declare module 'cordis' { @@ -129,6 +132,22 @@ export abstract class TaskService extends Service { */ abstract onTaskDone(listener: TaskDoneListener): () => void + /** + * Register an effect-scoped observer of visible-set changes. It fires after + * every commit that changes what {@link list} returns for that owner — + * registration, the stopping transition, settlement, and owner-disposal + * removal — so an observer re-reads rather than accumulating deltas. + * + * This is not a superset of {@link onTaskDone}: that one delivers the terminal + * record under first-wins semantics a control surface couples to notice + * delivery, while this one carries no delivery meaning and marks nothing + * reported. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned task changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ + abstract onTasksChanged(listener: TasksChangedListener): () => void + /** * Attach an effect-scoped surface that can read and stop tasks. {@link start} * refuses work while none is attached. diff --git a/packages/tasks/tasks/src/types.ts b/packages/tasks/tasks/src/types.ts index d722f3fce6..a05b75817c 100644 --- a/packages/tasks/tasks/src/types.ts +++ b/packages/tasks/tasks/src/types.ts @@ -4,24 +4,11 @@ * @module @deepseek-ai/dsh-tasks/types */ -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 { TaskId } from './brand.ts' -/** - * Identifies a background task. The registry generates `-N`; predictable - * ids rely on owner authorization rather than secrecy. - */ -export type TaskId = Branded<'TaskId'> - -/** - * Brand a string as a {@link TaskId}. - * @param id - the raw task-id string (the registry generates `-N`). - * @returns the same string, branded; no validation is performed. - */ -export function TaskId(id: string): TaskId { - return id as TaskId -} +export { TaskId } from './brand.ts' /** * Task lifecycle: `running`, optionally `stopping`, then exactly one terminal @@ -157,3 +144,14 @@ export type TaskDoneListener = ( snapshot: TaskSnapshot, owner: Agent | undefined, ) => void | PromiseLike + +/** + * Observation callback for a change to what one owner's {@link TaskService.list} + * would return. It is owner-granular rather than task-granular because the + * change may be a removal, which no per-task record can express, and because + * its consumers re-read the whole visible set anyway. + * + * An `undefined` owner means an unowned task changed, so every caller's visible + * set changed with it. + */ +export type TasksChangedListener = (owner: Agent | undefined) => void diff --git a/packages/tasks/tasks/tests/service.spec.ts b/packages/tasks/tasks/tests/service.spec.ts index 82fc415f51..ccd3106c22 100644 --- a/packages/tasks/tasks/tests/service.spec.ts +++ b/packages/tasks/tasks/tests/service.spec.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { TaskId, TaskService } from '@deepseek-ai/dsh-tasks' -import type { TaskDoneListener, TaskRead, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' +import type { + TaskDoneListener, TaskRead, TaskSnapshot, TaskStart, TasksChangedListener, +} from '@deepseek-ai/dsh-tasks' /** * Minimal concrete registry: one canned record. The seam owns the contract @@ -50,6 +52,10 @@ class StubTaskService extends TaskService { return () => {} } + onTasksChanged(_listener: TasksChangedListener): () => void { + return () => {} + } + attachSurface(_name: string): () => void { return () => {} } @@ -70,6 +76,8 @@ describe('TaskService seam', () => { await expect(ctx.tasks.wait(id, 5)).resolves.toMatchObject({ id }) const detachListener = ctx.tasks.onTaskDone(() => {}) detachListener() + const detachChanges = ctx.tasks.onTasksChanged(() => {}) + detachChanges() detachSurface() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccbc67c764..e0043229cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1281,6 +1281,9 @@ importers: '@deepseek-ai/dsh-client-ui-subagent': specifier: workspace:^ version: link:../../client/ui-subagent + '@deepseek-ai/dsh-client-ui-task': + specifier: workspace:^ + version: link:../../client/ui-task '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme @@ -2324,6 +2327,40 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/client/ui-task: + 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 + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/ui-theme: dependencies: clsx: @@ -3898,6 +3935,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 54581a8605..f1a5d6e965 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -191,6 +191,7 @@ export const LINK_MAP: Readonly> = { TaskRead: 'tasks.md', TaskSnapshot: 'tasks.md', TaskStart: 'tasks.md', + TasksChangedListener: 'tasks.md', TokenMeasurement: 'token-meter.md', CodeDispatchLog: 'tools.md', PostToolDecision: 'tools.md', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 3ca0317cf4..d0fdef95bd 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -65,6 +65,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-task': { kind: 'none', reason: 'Browser-side read-only projection of ctx.tasks records; dsh-tool-tasks owns the model-facing surface.' }, '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 target at the next prompt-assembly boundary and owns the model-visible effect.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index de9b63459c..274b75d37a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -71,6 +71,7 @@ "@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"], "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/ui/commands/src/brand.ts"], + "@deepseek-ai/dsh-tasks/brand": ["./packages/tasks/tasks/src/brand.ts"], "@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], @@ -171,6 +172,7 @@ "@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], + "@deepseek-ai/dsh-client-ui-task": ["./packages/client/ui-task/src"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 9ce72753c1..f03e452a57 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -66,6 +66,7 @@ { "path": "./packages/client/ui-command" }, { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-subagent" }, + { "path": "./packages/client/ui-task" }, { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, { "path": "./packages/client/ui-permission" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 6641a505b9..5e1b0a8e70 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -54,6 +54,7 @@ "apps/web/tests/produced-files.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", + "apps/web/tests/background-task-list.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", "apps/web/tests/turn-tail-actions.e2e.ts", From 37a75f9b7f2292849a856f46c7f48fa2020e65d1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 9 Aug 2026 00:00:33 +0800 Subject: [PATCH 04/77] docs: regenerate the module graph for client-ui-task The new package and its dependency edges changed the generated graph; `verify-module-graph` runs in the CI static gate rather than doc-sync, so the stale file only surfaced after the push. --- docs/module-graph.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/module-graph.md b/docs/module-graph.md index 22af77f9c5..04e1870dae 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -183,6 +183,7 @@ flowchart TD pkg_client_ui_slash["client-ui-slash"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] + pkg_client_ui_task["client-ui-task"] pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_tool["client-ui-tool"] pkg_client_ui_trajectory["client-ui-trajectory"] @@ -892,6 +893,12 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants + pkg_client_ui_task --> pkg_client_locale + pkg_client_ui_task --> pkg_client_runtime + pkg_client_ui_task --> pkg_client_ui_conversation + pkg_client_ui_task --> pkg_client_ui_primitives + pkg_client_ui_task --> pkg_client_ui_slots + pkg_client_ui_task --> pkg_invariants pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1317,6 +1324,7 @@ flowchart TD | [`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-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) | | [`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) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | From 1db1cda4644dd5df46c149e4c36faba04c9d67d3 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 11:15:01 +0800 Subject: [PATCH 05/77] fix(subagent): keep output past an empty terminal message with one selection rule A max-tokens step that assembled only tool-call blocks appends an EMPTY-content assistant/message (the usage host). Three consumers each hand-rolled output selection and all let it erase the child's real answer: the in-process readResult and the Activation subagent/end capture took the last message unfiltered, and the SDK backend let any message beat its streamed-text fallback; the in-process driver also had no streamed-text fallback for cancelled turns. dsh-subagent now owns the canonical rule in src/assistant-output.ts (last non-empty assistant message, else the accumulated text-delta stream) and all three consumers apply it. Regression tests in all three packages fail under the previous selections. Closes #1514 --- ...nt-empty-terminal-message-output.i18n.yaml | 6 ++ ...-subagent-empty-terminal-message-output.md | 27 +++++++++ ...bagent-empty-terminal-message-output.zh.md | 27 +++++++++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 8 +-- docs/event-producer-consumer.zh.md | 8 +-- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 17 ++++-- docs/subsystems/subagent.zh.md | 17 ++++-- .../scaffold/client/tests/fake-runtime.ts | 8 ++- .../subagent-dsh-sdk/README.i18n.yaml | 4 +- packages/subagent/subagent-dsh-sdk/README.md | 2 +- .../subagent/subagent-dsh-sdk/README.zh.md | 2 +- packages/subagent/subagent-dsh-sdk/src/run.ts | 16 +++-- .../tests/subagent-dsh-sdk.spec.ts | 14 +++++ .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 7 ++- .../tests/subagent-inprocess.spec.ts | 39 +++++++++++- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/assistant-output.ts | 51 ++++++++++++++++ packages/subagent/subagent/src/index.ts | 1 + packages/subagent/subagent/src/lifecycle.ts | 16 +---- packages/subagent/subagent/src/types.ts | 13 +++- .../subagent/tests/assistant-output.spec.ts | 60 +++++++++++++++++++ .../subagent/tests/continuation.spec.ts | 43 ++++++++++++- 29 files changed, 344 insertions(+), 66 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md create mode 100644 packages/subagent/subagent/src/assistant-output.ts create mode 100644 packages/subagent/subagent/tests/assistant-output.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml new file mode 100644 index 0000000000..9ec2d8bb33 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.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/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md +2026-08-10-subagent-empty-terminal-message-output.md: ece2a55930aabfaeaf88ef1757918296b32bfea5 +2026-08-10-subagent-empty-terminal-message-output.zh.md: 00b8f5bd7bd8e71935f68af2c35521f2b2377186 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md new file mode 100644 index 0000000000..ece2a55930 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md @@ -0,0 +1,27 @@ +# Agent Note: One selection rule keeps subagent output past an empty terminal message + +Status: implemented + +English | [中文](2026-08-10-subagent-empty-terminal-message-output.zh.md) + +## Problem + +The agent loop appends an EMPTY-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks (`BlockAssembler.blocks()` drops truncated tool calls): the message exists solely to host usage. Three consumers each selected "the child's answer" with their own rule and all treated that usage host as the answer. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture took the LAST `assistant/message` unfiltered, and the SDK backend's observer let any `assistant/message` beat its streamed-text fallback. In a multi-step turn cut off at max-tokens, the final empty message therefore erased the real partial answer: `SubagentResult.output` came back `[]`, and the tool result, telemetry, and `subagent/end.lastAssistantMessage` all saw nothing. The in-process driver additionally had no streamed-text fallback at all, so a cancelled child whose only text lived in `assistant/chunk` events also reported `[]`. + +## Decision + +`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: the last NON-EMPTY assistant message wins; without one, the accumulated `text-delta` stream is the answer; empty-content messages never contribute. `finalAssistantOutput(events)` applies the rule to an event suffix (the in-process `readResult` and the Activation capture), and `assistantMessageOutput(event)` is the same per-event predicate for the SDK backend's incremental fold. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` declares it selects by the same rule. A `max-tokens` or `aborted` finish still reports its honest stop reason; only output selection changed. + +The ACP backend accumulates chunks only and was never affected. The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message. + +## Alternatives considered + +**Fix each consumer in place without a shared helper.** Rejected: the defect existed precisely because three hand-rolled selections drifted; observers of one run must agree on its answer, so the rule needs one implementation (the drafts that first proved the defect, PR #1140 and PR #1141, patched two of the three call sites separately and left the Activation capture inconsistent). + +**Stop the loop from appending the empty message.** Rejected: the message is the usage host and the step's durable record ("model-visible ⟺ logged"); reshaping session events for a consumer-side selection bug would touch every replay and projection consumer. + +**Treat empty-content messages as an error.** Rejected: the streamed text is the child's real partial answer, and the stop reason already tells the consumer the turn was cut short. + +## Consequences + +Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children keep the text streamed before the abort; one-shot and continuable `subagent/end` edges agree with `SubagentResult.output`. A message whose content is non-empty but textless (for example reasoning-only) still wins over streamed text — the rule is about empty content, not text presence. Regression tests in all three packages script the empty-terminal-message and cancel paths and fail under the previous selections. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md new file mode 100644 index 0000000000..00b8f5bd7b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 用同一条选取规则在空终止消息后保留子代理输出 + +Status: implemented + +[English](2026-08-10-subagent-empty-terminal-message-output.md) | 中文 + +## 问题 + +当 `max-tokens` 步骤只组装了工具调用块时(`BlockAssembler.blocks()` 会丢弃被截断的工具调用),agent loop 会追加一条内容为**空**的 `assistant/message`——这条消息仅用于承载 usage。三个消费方各自用自己的规则选取"子代理的回答",并且都把这个 usage 宿主当成了回答:进程内驱动的 `readResult` 和 continuable Activation 的 `subagent/end` capture 不加过滤地取**最后一条** `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 覆盖其流式文本兜底。于是在被 max-tokens 截断的多步回合中,最后那条空消息抹掉了真实的部分回答:`SubagentResult.output` 返回 `[]`,工具结果、遥测和 `subagent/end.lastAssistantMessage` 全都看不到任何内容。此外进程内驱动完全没有流式文本兜底,因此被取消的子代理若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。 + +## 决策 + +`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:最后一条**非空** assistant 消息优先;没有时,累积的 `text-delta` 流就是回答;空内容消息从不参与。`finalAssistantOutput(events)` 把该规则应用于事件后缀(进程内 `readResult` 与 Activation capture),`assistantMessageOutput(event)` 是同一规则的逐事件谓词,供 SDK 后端的增量折叠使用。契约在 `SubagentResult.output` 处声明一次,并由子系统参考文档镜像;`subagent/end.lastAssistantMessage` 声明按同一规则选取。`max-tokens` 或 `aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。 + +ACP 后端只累积分块,从未受影响。fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息。 + +## 考虑过的替代方案 + +**各消费方就地修复、不抽共享辅助函数。** 之所以否决:缺陷恰恰源于三处手写选取的漂移;同一次运行的观察方必须对其回答达成一致,因此规则需要唯一实现(最早证明该缺陷的草稿 PR #1140 与 PR #1141 分别修补了三处调用点中的两处,留下 Activation capture 不一致)。 + +**让 loop 不再追加空消息。** 之所以否决:这条消息是 usage 宿主,也是该步骤的持久化记录("model-visible ⟺ logged");为一个消费方侧的选取缺陷重塑会话事件,会波及所有 replay 与 projection 消费方。 + +**把空内容消息视为错误。** 之所以否决:流式文本才是子代理真实的部分回答,且终止原因已经告诉消费方轮次被截断。 + +## 后果 + +被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0a3d6007f1..4b9645c425 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: 11eecf81a4eccadf2b97026154a78e4ed8a72164 -event-producer-consumer.zh.md: 2db5e596465b4adaf98c1b05692b61de3ced47b9 +event-producer-consumer.md: 1849bf5bd4df5e58370b1eaf5846f14a604fa4f6 +event-producer-consumer.zh.md: f280c50076fa2799e52556aa31a6d2316c151e17 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 11eecf81a4..1849bf5bd4 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,10 +37,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:154`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2db5e59646..f280c50076 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -39,10 +39,10 @@ | `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) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:284`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:137`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:143`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:154`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index e98438900d..554c7a1662 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subagent.md -subagent.md: 99c54e0696c9c3585c50285ecb69b14c90d83c11 -subagent.zh.md: c5a79247a81f5174662f2b5d1ed2d3c20cf6c672 +subagent.md: 4d72ca7a3fd42438e46a50558287e0e85450963d +subagent.zh.md: e8b1c948fa0ac3fe47097f0dd8f2532bdb4a8d20 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 99c54e0696..4d72ca7a3f 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -293,7 +293,12 @@ The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. */ interface SubagentResult { - /** The child's final assistant output (the last assistant message's content). */ + /** + * The child's final assistant output: the content of the last NON-EMPTY + * assistant message (an empty-content message hosts only usage and is + * skipped), else the text streamed before the turn was cut short, or `[]` + * when the child produced none. + */ readonly output: ContentBlock[] /** * The structured result after a requested `outputSchema` was successfully @@ -613,7 +618,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) -Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:168`](../../packages/subagent/subagent/src/index.ts) @@ -639,7 +644,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:163`](../../packages/subagent/subagent/src/index.ts) @@ -656,7 +661,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:137`](../../packages/subagent/subagent/src/index.ts) @@ -673,7 +678,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:143`](../../packages/subagent/subagent/src/index.ts) @@ -697,5 +702,5 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:154`](../../packages/subagent/subagent/src/index.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index c5a79247a8..e8b1c948fa 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -293,7 +293,12 @@ type SubagentDescendantListEntry = SubagentListEntry & { * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. */ interface SubagentResult { - /** The child's final assistant output (the last assistant message's content). */ + /** + * The child's final assistant output: the content of the last NON-EMPTY + * assistant message (an empty-content message hosts only usage and is + * skipped), else the text streamed before the turn was cut short, or `[]` + * when the child produced none. + */ readonly output: ContentBlock[] /** * The structured result after a requested `outputSchema` was successfully @@ -615,7 +620,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) -Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:168`](../../packages/subagent/subagent/src/index.ts) @@ -641,7 +646,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:163`](../../packages/subagent/subagent/src/index.ts) @@ -658,7 +663,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:137`](../../packages/subagent/subagent/src/index.ts) @@ -675,7 +680,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:143`](../../packages/subagent/subagent/src/index.ts) @@ -699,5 +704,5 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:154`](../../packages/subagent/subagent/src/index.ts) diff --git a/packages/scaffold/client/tests/fake-runtime.ts b/packages/scaffold/client/tests/fake-runtime.ts index 85d5253765..0462fabf4a 100644 --- a/packages/scaffold/client/tests/fake-runtime.ts +++ b/packages/scaffold/client/tests/fake-runtime.ts @@ -26,6 +26,9 @@ * array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data * member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare * string (wire-validation probes). + * - `FAKE_EMPTY_MESSAGE`: the turn's assistant/message has EMPTY content (a + * usage-only max-tokens step) after streaming the text chunk — a consumer + * must keep the streamed text instead of the empty message. * - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe). * - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize` * arrives, then poll for the GO file before answering (deterministic @@ -117,7 +120,10 @@ function runTurn(sessionId: string): void { message: { id: `fake-assistant-${seq}`, role: 'assistant', - content: [{ type: 'text', text }], + // FAKE_EMPTY_MESSAGE: a usage-only terminal message (the harness loop + // appends one when a max-tokens step assembled no text blocks) whose + // empty content must not erase the text streamed above. + content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }], source: { kind: 'model', provider: 'fake', model: 'fake' }, }, }) diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index b070f9f5d6..cbe3becb25 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/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/subagent/subagent-dsh-sdk/README.md -README.md: 0bbcfa105ecf024a2492d39d3bf8d28956110050 -README.zh.md: 8c8551f85951aa8475ab2ce95771e4d54e0ed89a +README.md: 80f5c40a2c949b7c2a638ec19c02950e8cd69f0b +README.zh.md: b34421dbbf2d06b7c9236776aabf19ba8005204e diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 0bbcfa105e..80f5c40a2c 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -10,7 +10,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. -The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths. +The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete NON-EMPTY `assistant/message` (an empty-content message hosts only usage and is skipped), or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths. `dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit. diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index 8c8551f859..b34421dbbf 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -10,7 +10,7 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS 工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。 -返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整的 `assistant/message`,或该活动中断前已经累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。 +返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且**非空**的 `assistant/message`(空内容消息仅承载 usage,会被跳过),或该活动中断前已经累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。 `dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。 diff --git a/packages/subagent/subagent-dsh-sdk/src/run.ts b/packages/subagent/subagent-dsh-sdk/src/run.ts index b8a01ea383..39bf53fffb 100644 --- a/packages/subagent/subagent-dsh-sdk/src/run.ts +++ b/packages/subagent/subagent-dsh-sdk/src/run.ts @@ -16,7 +16,7 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk- import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent' +import { assistantMessageOutput, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' /** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */ @@ -163,17 +163,21 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe } const childSessionId = `session-${randomUUID().replaceAll('-', '')}` - // The child's final answer: the last complete assistant message when one - // exists, else the text streamed so far (a partial answer surviving cancel). + // The child's final answer, folded incrementally under the seam's canonical + // rule (`finalAssistantOutput`): the last NON-EMPTY complete assistant + // message when one exists, else the text streamed so far (a partial answer + // surviving cancel). An empty-content message hosts only usage (a max-tokens + // step that assembled no text blocks), so it never erases streamed text. let lastMessage: ContentBlock[] | undefined const partial: string[] = [] const observe = (notification: HarnessNotification): void => { if (notification.method !== 'session.event' || notification.params.sessionId !== childSessionId) return const event = notification.params.event as SessionEvent - if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { + const content = assistantMessageOutput(event) + if (content !== undefined) { + lastMessage = content + } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { partial.push(event.data.chunk.text) - } else if (event.type === 'assistant/message') { - lastMessage = event.data.message.content } } const collectOutput = (): ContentBlock[] => { diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index e42a76c90c..c00dfe5fec 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -176,6 +176,20 @@ describe('dsh-subagent-dsh-sdk provider', () => { await ctx.fiber.dispose() }) + it('keeps streamed text when the terminal message is an EMPTY usage-only step', async () => { + // The child streams its answer, then emits an empty-content + // assistant/message (the harness loop appends one to host usage on a + // max-tokens step that assembled no text blocks). The empty message is + // not assistant output and must not erase the streamed answer. + const ctx = await setup({ FAKE_EMPTY_MESSAGE: '1', FAKE_REASON_KIND: 'max-tokens' }) + const run = await ctx.subagents.start('dsh-sdk', request()) + const result = await run.result + expect(result.stopReason).toBe('max-tokens') + expect(text(result.output)).toBe('hello from fake runtime') + await run.dispose() + await ctx.fiber.dispose() + }) + it('reports a settled-without-turn child as an error', async () => { const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' }) const run = await ctx.subagents.start('dsh-sdk', request()) diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index a6a82fb47f..ed5606cbc9 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 67f0cf5dd1ecb18542af56953a0eaa40988aca0d -README.zh.md: 648a160be5f1c3dcbe66a867a273a3df610dbc0a +README.md: 2e2a3873843467b0811ccbc0ed1d9bb6a83eb31f +README.zh.md: 91013164e762bb01e7ad5a51597c6fa559c8d7a3 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 67f0cf5dd1..2e2a387384 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ The driver follows this sequence: 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. Read the child's own last assistant message and final durable turn reason from the complete owned child run, excluding any fork seed. +5. Read the child's own output — its last NON-EMPTY assistant message (an empty-content message hosts only usage and is skipped), else the text it streamed before cancel or truncation cut the turn short — and the final durable turn reason from the complete owned child run, excluding any fork seed. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 648a160be5..91013164e7 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 从完整的自有子运行中读取子 agent 自身最后一条 assistant 消息和最终持久化的轮次原因,并排除任何 fork 初始内容。 +5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条**非空** assistant 消息(空内容消息仅承载 usage,会被跳过),否则取轮次被取消或截断前已流式的文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index acb4e4d36e..d621674a6e 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -20,6 +20,7 @@ import { applyChildComposition, assertSubagentMaxDepth, childSessionMeta, + finalAssistantOutput, resolveChildAgentOptions, resolveChildDepth, } from '@deepseek-ai/dsh-subagent' @@ -218,9 +219,11 @@ function readResult( structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { const own = child.session.events.slice(boundary) - const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message') const lastEnd = findLastMessageTurnEnd(own) - const output: ContentBlock[] = lastMessage?.data.message.content ?? [] + // Canonical selection (`finalAssistantOutput`): the last non-empty assistant + // message, else the text streamed before cancel/error/truncation cut the + // turn short — an empty usage-only message never erases real output. + const output: ContentBlock[] = finalAssistantOutput(own) ?? [] const recorded = toStopReason(lastEnd?.data.reason) // Disposal can tear the owner down before the loop records its ordinary // `aborted` end, yielding `disposed` instead. diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index d175322381..471fc46bdd 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,4 +1,4 @@ -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' @@ -10,7 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -155,6 +156,33 @@ describe('startInProcessRun', () => { await run.dispose() }) + it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => { + // Step 1 streams "partial one" plus a tool call; step 2 hits max-tokens + // having assembled only a tool-call block, so the loop appends an EMPTY + // assistant/message to host usage. The empty message is not assistant + // output and must not erase step 1's text from the run's output. + const { ctx, parent } = await setup([ + toolCallResponse('t1', 'noop', {}, 'partial one'), + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'max-tokens' } }, + ], + ]) + const disposeNoop = ctx.tools.register(defineContentToolFixture({ + name: 'noop', description: 'probe', parameters: {}, + execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) }, + })) + const run = await startInProcessRun(request(parent), {}) + const result = await run.result + expect(result.stopReason).toBe('max-tokens') + expect(text(result.output)).toBe('partial one') + await run.dispose() + disposeNoop() + }) + it('seeds a forked child but reads only the child-owned output', async () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) @@ -278,7 +306,12 @@ describe('startInProcessRun', () => { const signalled = await startInProcessRun(request(parent, controller.signal), {}) await new Promise(resolve => setTimeout(resolve, 30)) controller.abort('stop child') - await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' }) + // No step completed a message, so the text streamed before the abort is + // the cancelled run's output. + await expect(signalled.result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial' }], + stopReason: 'aborted', + }) expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) const child = parent.ctx.agents.get(signalled.id) const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end') diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 1241aa8042..2b47a91a4b 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 762030629c09305c48adebc71244655a5faa6585 -README.zh.md: 535cc25895e04e82b6667e6d2769f2dcbfa49cff +README.md: d2d5356fd82a47ecf5cd6b633e5338dde7047901 +README.zh.md: 2fdc3ae6e8376ef7c7aaf11c8e85dd909ef92d2c diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 762030629c..d2d5356fd8 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -56,7 +56,7 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `provider.start(request): Promise` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. +`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` edge's `lastAssistantMessage` share one selection rule, implemented by the exported `finalAssistantOutput` helper: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the contract). A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 535cc25895..2fdc3ae6e8 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -56,7 +56,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。 +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 边沿的 `lastAssistantMessage` 共用同一条选取规则,由导出的 `finalAssistantOutput` 辅助函数实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。 diff --git a/packages/subagent/subagent/src/assistant-output.ts b/packages/subagent/subagent/src/assistant-output.ts new file mode 100644 index 0000000000..5fea60fa89 --- /dev/null +++ b/packages/subagent/subagent/src/assistant-output.ts @@ -0,0 +1,51 @@ +/** + * Canonical selection of a child's final assistant output from its session + * events. Every surface that reports "the child's answer" — backend run + * results and `subagent/end.lastAssistantMessage` — applies this one rule so + * observers agree: the last NON-EMPTY assistant message wins; an empty-content + * message hosts only usage (the loop appends one when a max-tokens step + * assembled no executable blocks) and never erases real output; without any + * non-empty message, the text streamed so far is the answer (a partial + * surviving cancel, error, and truncation paths). + * + * @module @deepseek-ai/dsh-subagent/assistant-output + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * The content one event contributes as a candidate final answer: an + * `assistant/message` with non-empty content. An empty-content message hosts + * only usage and contributes none. + * @param event - any session event. + * @returns the message content, or `undefined` when this event is not a + * non-empty assistant message. + */ +export function assistantMessageOutput(event: SessionEvent): ContentBlock[] | undefined { + if (event.type !== 'assistant/message') return undefined + const content = event.data.message.content + return content.length > 0 ? content : undefined +} + +/** + * Select the final assistant output from one child-owned event suffix: the + * last non-empty assistant message, else the accumulated `text-delta` stream. + * @param events - the child-owned events (after any seed or epoch boundary). + * @returns the selected output, or `undefined` when the child produced none. + */ +export function finalAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { + let message: ContentBlock[] | undefined + const partial: string[] = [] + for (const event of events) { + const content = assistantMessageOutput(event) + if (content !== undefined) { + message = content + } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { + partial.push(event.data.chunk.text) + } + } + if (message !== undefined) return message + const text = partial.join('') + return text.length > 0 ? [{ type: 'text', text }] : undefined +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index eddcf63c3c..d3e26e6dbc 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -69,6 +69,7 @@ import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' +export { assistantMessageOutput, finalAssistantOutput } from './assistant-output.ts' export { SubagentRunId } from './types.ts' export type { ContinuableCreateRequest, diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 65c61ae9eb..26fdd54256 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -20,6 +20,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { finalAssistantOutput } from './assistant-output.ts' import { SubagentRunId } from './types.ts' import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' @@ -173,7 +174,7 @@ export function createActivationObserver( }, capture: (child: Agent): void => { const own = child.session.events.slice(boundary) - const output = lastAssistantOutput(own) + const output = finalAssistantOutput(own) captured = { stopReason: epochStopReason(own), ...output === undefined ? {} : { output }, @@ -220,19 +221,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR } } -/** - * The child's last assistant message content, for one Activation's terminal - * lifecycle edge. Absent when no assistant message reached the log. - * @param events - this epoch's own event suffix. - * @returns its final assistant content, or `undefined` when it produced none. - */ -function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { - const message = events.findLast( - (event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message', - ) - return message?.data.message.content -} - /** Render any listener-thrown value without letting coercion escape containment. */ function renderThrown(value: unknown): string { try { diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index b1df485644..881d63980d 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -64,7 +64,11 @@ export interface SubagentRunEndInfo { readonly local: boolean /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] - /** The child's final assistant output, absent on infrastructure rejection. */ + /** + * The child's final assistant output, selected by the same rule as + * {@link SubagentResult.output}; absent on infrastructure rejection or when + * the child produced none. + */ readonly lastAssistantMessage?: ContentBlock[] } @@ -213,7 +217,12 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. */ export interface SubagentResult { - /** The child's final assistant output (the last assistant message's content). */ + /** + * The child's final assistant output: the content of the last NON-EMPTY + * assistant message (an empty-content message hosts only usage and is + * skipped), else the text streamed before the turn was cut short, or `[]` + * when the child produced none. + */ readonly output: ContentBlock[] /** * The structured result after a requested `outputSchema` was successfully diff --git a/packages/subagent/subagent/tests/assistant-output.spec.ts b/packages/subagent/subagent/tests/assistant-output.spec.ts new file mode 100644 index 0000000000..5219209249 --- /dev/null +++ b/packages/subagent/subagent/tests/assistant-output.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { assistantMessageOutput, finalAssistantOutput } from '../src/assistant-output.ts' + +function message(content: ContentBlock[]): SessionEvent { + return { type: 'assistant/message', data: { message: { content } } } as SessionEvent +} + +function textDelta(text: string): SessionEvent { + return { type: 'assistant/chunk', data: { chunk: { type: 'text-delta', text } } } as SessionEvent +} + +function reasoningDelta(text: string): SessionEvent { + return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent +} + +describe('assistantMessageOutput', () => { + it('returns content only for a non-empty assistant message', () => { + const content: ContentBlock[] = [{ type: 'text', text: 'answer' }] + expect(assistantMessageOutput(message(content))).toBe(content) + expect(assistantMessageOutput(message([]))).toBeUndefined() + expect(assistantMessageOutput(textDelta('chunk'))).toBeUndefined() + }) +}) + +describe('finalAssistantOutput', () => { + it('selects the last non-empty message past a later empty usage-only message', () => { + const events = [ + message([{ type: 'text', text: 'step one' }]), + message([{ type: 'text', text: 'step two' }]), + message([]), + ] + expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }]) + }) + + it('prefers a non-empty message over the streamed text', () => { + const events = [ + textDelta('streamed '), + textDelta('text'), + message([{ type: 'text', text: 'complete answer' }]), + ] + expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }]) + }) + + it('falls back to accumulated text deltas when no non-empty message exists', () => { + const events = [ + reasoningDelta('thinking'), + textDelta('partial '), + textDelta('answer'), + message([]), + ] + expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'partial answer' }]) + }) + + it('returns undefined when the child produced neither messages nor text', () => { + expect(finalAssistantOutput([])).toBeUndefined() + expect(finalAssistantOutput([reasoningDelta('thinking'), message([])])).toBeUndefined() + }) +}) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9370676f76..b60ae57f99 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -12,10 +12,10 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' -import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import InvariantService from '@deepseek-ai/dsh-invariants' -import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { SubagentError, SUBAGENT_DESCRIPTOR_VERSION, @@ -1200,6 +1200,45 @@ describe('continuable review regressions', () => { expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }]) }) + it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => { + // Step 1 streams text plus a tool call; step 2 hits max-tokens having + // assembled only a tool-call block, so the loop appends an EMPTY + // assistant/message to host usage. The terminal edge reports the epoch's + // real answer text, not the internal usage marker. + const { ctx, parent } = await setup([ + toolCallResponse('t1', 'noop', {}, 'partial one'), + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'max-tokens' } }, + ], + ]) + ctx.tools.register(defineTool({ + name: 'noop', + description: 'does nothing', + parameters: {}, + output: { + schema: { type: 'object', additionalProperties: false, properties: {} }, + render: () => [{ type: 'text', text: 'noop' }], + }, + execute: () => Promise.resolve({}), + })) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + expect(ends[0]!.stopReason).toBe('max-tokens') + expect(ends[0]!.lastAssistantMessage).toEqual([ + { type: 'text', text: 'partial one' }, + { type: 'tool-call', id: 't1', name: 'noop', arguments: '{}' }, + ]) + }) + it('reports a resumed epoch that opened no turn without the previous answer', async () => { const { ctx, parent } = await setup([textResponse('first answer')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) From 15d452d7ea2512e3d7a4f4569f820e4cc8482354 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 13:37:18 +0800 Subject: [PATCH 06/77] fix(tasks): correct the change-feed contract and its documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the `onTasksChanged` teardown reasoning inverted. The comment claimed every registration is an effect on the registry's own fiber, so listeners would be gone before service disposal empties the store — but the traceable proxy rebinds `this.ctx` to the CALLER, which this package's own HMR-safety test already proves. The only shipped consumer registers from the api-proxy mux stream, so it was still listening and simply kept the rows it last received. Service disposal now announces the emptied set, and teardown announces its stopping transition immediately instead of leaving an observer on `running` for however long a slow producer takes to release. Two documentation claims were false in the opposite direction: the Agent Note and the ui-task README both said an unowned task is invisible in the header, while `list(caller)` returns unowned tasks to every caller, the carrier fans their changes out to every subscribed session, and this PR's own test asserts exactly that. The note even contradicted itself two sections earlier. Both sides now state the real asymmetries — another session's tasks, and the process-local registry emptying on restart. The "no Web path calls the consuming `ctx.tasks.read()`" invariant claimed a test that did not exist; the carrier suite's producer had no `readOutput` at all, so a stray read would have failed nothing. Its producer now counts cursor consumption and the lifecycle and baseline paths both assert zero. Also: a session created after the mux opened now receives the task baseline it missed, the popover samples its clock when it opens rather than at mount, and a failed task's unbounded producer detail elides instead of widening the row. --- ...8-08-web-background-task-display.i18n.yaml | 4 +- .../2026-08-08-web-background-task-display.md | 2 +- ...26-08-08-web-background-task-display.zh.md | 2 +- apps/web/tests/background-task-list.e2e.ts | 5 +- docs/subsystems/tasks.i18n.yaml | 4 +- docs/subsystems/tasks.md | 10 +++- docs/subsystems/tasks.zh.md | 10 +++- packages/client/ui-task/README.i18n.yaml | 4 +- packages/client/ui-task/README.md | 2 +- packages/client/ui-task/README.zh.md | 2 +- .../src/client/TaskListAction.module.css | 9 +++ .../ui-task/src/client/TaskListAction.tsx | 11 +++- packages/host/apiproxy/src/api-proxy.ts | 8 +++ .../apiproxy/tests/api-proxy-tasks.spec.ts | 59 ++++++++++++++++++- .../tool-cordis/src/api-catalog.ts | 2 +- packages/tasks/tasks-local/src/index.ts | 15 +++-- .../tasks/tasks-local/tests/tasks.spec.ts | 54 +++++++++++++++++ packages/tasks/tasks/README.i18n.yaml | 4 +- packages/tasks/tasks/README.md | 2 +- packages/tasks/tasks/README.zh.md | 2 +- packages/tasks/tasks/src/index.ts | 10 +++- 21 files changed, 192 insertions(+), 29 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml index f00211b5f4..9984adfa0f 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.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-08-web-background-task-display.md -2026-08-08-web-background-task-display.md: 17ac109082ab3b2afea92194d0d148cf11ba9d0a -2026-08-08-web-background-task-display.zh.md: 1049218337caae3bd95c6f6ff3183589d0ce9d15 +2026-08-08-web-background-task-display.md: 558d0e26b3b8da83602296c5fdd0944886d4eab7 +2026-08-08-web-background-task-display.zh.md: 9c9f4643bb08c748381ede6bf585858ca1204ff7 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md index 17ac109082..558d0e26b3 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md @@ -125,7 +125,7 @@ Below it, [`tasks-local`](../../../../packages/tasks/tasks-local/tests/tasks.spe **Unowned-task fan-out is easy to under-implement.** Pushing only to the changed owner's session is correct for owned tasks and silently wrong for unowned ones, which are visible everywhere. The bug would surface only in compositions that create unowned tasks, which is why the carrier suite covers it directly. -**The UI set is not the registry's set.** An unowned task is invisible in the header while `task_list` still reports it to the model. In practice every tool call carries an agent, so this stays theoretical, but the two surfaces are not interchangeable. +**The UI set is not the registry's set.** The header shows what one session can see, so a task owned by another session never appears in it even though the registry holds it — and because the registry is process-local, a restart empties every list while the transcript still shows the `run_in_background` cards that started them. Unowned tasks are the opposite case: they reach every session's list, exactly as `list(caller)` reports them to every caller. **Settled rows accumulate.** The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md index 1049218337..9c9f4643bb 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md @@ -125,7 +125,7 @@ abstract onTasksChanged(listener: TasksChangedListener): () => void **无主任务的扇出很容易做漏。** 只推给变更 owner 所在的会话,对有主任务是对的,对处处可见的无主任务则是悄悄错的。这个 bug 只会在会创建无主任务的组合里显形,所以载体套件直接覆盖了它。 -**UI 的集合不等于注册表的集合。** 无主任务在 header 里不可见,而 `task_list` 仍会把它报告给模型。实践中每次工具调用都带着 agent,所以这停留在理论层面,但两个界面并不可互换。 +**UI 的集合不等于注册表的集合。** header 显示的是「一个会话能看到什么」,所以别的会话拥有的任务在这里永远不出现,尽管注册表里有它;而由于注册表是进程本地的,一次重启会清空所有列表,transcript 里那些启动它们的 `run_in_background` 卡片却还在。无主任务是反过来的情形:它们会进入每一个会话的列表,正如 `list(caller)` 对每个调用方都报告它们。 **终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。 diff --git a/apps/web/tests/background-task-list.e2e.ts b/apps/web/tests/background-task-list.e2e.ts index 217eda49fa..734d43131a 100644 --- a/apps/web/tests/background-task-list.e2e.ts +++ b/apps/web/tests/background-task-list.e2e.ts @@ -82,8 +82,11 @@ describe.skipIf(MODE === 'record')('web e2e: background task list', () => { it('shows a running background task in the session header without a refresh', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-running')) + // Point assertion, not a poll: `expect.poll` retries until a predicate + // holds, so polling for zero passes at t=0 and proves nothing. The + // "renders nothing without a task" branch is owned by the component suite. const trigger = page.getByRole('button', { name: '1 background task running' }) - await expect.poll(() => trigger.count(), { timeout: 5_000 }).toBe(0) + expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ signal: new AbortController().signal, diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index bd83fc1895..e6444d9b1a 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/tasks.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/tasks.md -tasks.md: fd4041698de2fd19156c2a87d378d7fafdd1d15a -tasks.zh.md: 00c8aab2794f99678e9707d2dfb7c67bf411b24d +tasks.md: 2ee062e1c410f7a26af273ca9adc70a978f25563 +tasks.zh.md: 9b6ea8260e439eca2ffffa3b50fb030f117c495c diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index fd4041698d..2ee062e1c4 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -248,8 +248,14 @@ abstract onTaskDone(listener: TaskDoneListener): () => void /** * Register an effect-scoped observer of visible-set changes. It fires after * every commit that changes what {@link list} returns for that owner — - * registration, the stopping transition, settlement, and owner-disposal - * removal — so an observer re-reads rather than accumulating deltas. + * registration, every stopping transition (including the one teardown + * performs before it awaits a slow producer), settlement, owner-disposal + * removal, and the emptying that service disposal commits — so an observer + * re-reads rather than accumulating deltas. + * + * The registration binds to the CALLING fiber, so an observer mounted outside + * this service still receives the disposal emptying; that is what stops a + * consumer from retaining rows after the registry unloads. * * This is not a superset of {@link onTaskDone}: that one delivers the terminal * record under first-wins semantics a control surface couples to notice diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index 00c8aab279..9b6ea8260e 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -248,8 +248,14 @@ abstract onTaskDone(listener: TaskDoneListener): () => void /** * Register an effect-scoped observer of visible-set changes. It fires after * every commit that changes what {@link list} returns for that owner — - * registration, the stopping transition, settlement, and owner-disposal - * removal — so an observer re-reads rather than accumulating deltas. + * registration, every stopping transition (including the one teardown + * performs before it awaits a slow producer), settlement, owner-disposal + * removal, and the emptying that service disposal commits — so an observer + * re-reads rather than accumulating deltas. + * + * The registration binds to the CALLING fiber, so an observer mounted outside + * this service still receives the disposal emptying; that is what stops a + * consumer from retaining rows after the registry unloads. * * This is not a superset of {@link onTaskDone}: that one delivers the terminal * record under first-wins semantics a control surface couples to notice diff --git a/packages/client/ui-task/README.i18n.yaml b/packages/client/ui-task/README.i18n.yaml index 45157f12e5..39f800e04b 100644 --- a/packages/client/ui-task/README.i18n.yaml +++ b/packages/client/ui-task/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-task/README.md -README.md: 318984808838cb7a336a4c34c95a2f27db578423 -README.zh.md: 7adeb254662fd23cf4bd1efc8618471cf74df7c0 +README.md: a1430db55c7519c612e5d39de4627c750976d2d5 +README.zh.md: 5e29c939806f9a6500e78324c5a3322b6fd94539 diff --git a/packages/client/ui-task/README.md b/packages/client/ui-task/README.md index 3189848088..a1430db55c 100644 --- a/packages/client/ui-task/README.md +++ b/packages/client/ui-task/README.md @@ -21,4 +21,4 @@ None; the package never assembles or sends provider requests. ## Known Limitations and Deferred Work - **Rows are read-only** — a task's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer today: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its task is still running. -- **The list is not the registry's own set** — it shows what the owning session can see through the wire view, so an unowned task (one started without a live `Agent`) is invisible here while `task_list` still reports it to the model. +- **The list is not the registry's own set** — it shows what one session can see through the wire view, so a task owned by another session never appears here, and a process restart empties the list while the transcript keeps the `run_in_background` cards that started those tasks. An unowned task (one started without a live `Agent`) is the opposite case: it reaches every session's list, matching what `list(caller)` reports to every caller. diff --git a/packages/client/ui-task/README.zh.md b/packages/client/ui-task/README.zh.md index 7adeb25466..5e29c93980 100644 --- a/packages/client/ui-task/README.zh.md +++ b/packages/client/ui-task/README.zh.md @@ -21,4 +21,4 @@ Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理 ## Known Limitations and Deferred Work - **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。 -- **列表不等于注册表自己的集合** —— 它展示的是拥有它的会话通过线路视图能看到的东西,因此一个无主任务(在没有活体 `Agent` 时启动的任务)在这里不可见,而 `task_list` 仍会把它报告给模型。 +- **列表不等于注册表自己的集合** —— 它展示的是「一个会话通过线路视图能看到什么」,所以别的会话拥有的任务在这里永远不出现;而进程重启会清空列表,transcript 里启动这些任务的 `run_in_background` 卡片却还在。无主任务(在没有活体 `Agent` 时启动的)是反过来的情形:它会进入每一个会话的列表,与 `list(caller)` 对每个调用方的报告一致。 diff --git a/packages/client/ui-task/src/client/TaskListAction.module.css b/packages/client/ui-task/src/client/TaskListAction.module.css index 6bf6ed2507..b6ac05aa31 100644 --- a/packages/client/ui-task/src/client/TaskListAction.module.css +++ b/packages/client/ui-task/src/client/TaskListAction.module.css @@ -111,6 +111,15 @@ line-height: 18px; } +/* A failed task's detail is the producer's raw error text, so it has no bound; + without this it widens the row past the menu instead of eliding like .label. */ +.status { + max-width: 40%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + .duration { font-variant-numeric: tabular-nums; } diff --git a/packages/client/ui-task/src/client/TaskListAction.tsx b/packages/client/ui-task/src/client/TaskListAction.tsx index 722d317aa9..ede2eca377 100644 --- a/packages/client/ui-task/src/client/TaskListAction.tsx +++ b/packages/client/ui-task/src/client/TaskListAction.tsx @@ -148,7 +148,14 @@ export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProp className={css.trigger} aria-expanded={open} aria-label={countLabel} - onClick={() => { setOpen(current => !current) }} + onClick={() => { + // Sample the clock in the same commit that opens the list: the + // mount-time value predates every task, so the first painted frame + // would otherwise clamp a long-running row to zero until the + // open effect corrects it a frame later. + setNow(Date.now()) + setOpen(current => !current) + }} > {liveCount > 0 ? : null} {countLabel} @@ -167,7 +174,7 @@ export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProp {task.kind} {task.label} - {task.detail ?? status} + {task.detail ?? status} { subscribeSession(queue, session) + // The subscribe frame clears the client's task mirror, and a + // session born after the stream opened missed the baseline loop. + // Unowned tasks are visible to it from birth, so without this it + // would show none until the next registry change. + const views = tasks === undefined ? [] : taskViews(tasks.list(ctx.agents.get(session.id))) + if (views.length > 0) { + queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views })) + } }), ctx.on('session/disposed', (session: Session) => { openCalls.delete(session.id) diff --git a/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts index 1af65962ca..ebfe44d375 100644 --- a/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts @@ -29,15 +29,19 @@ type TaskFrame = Extract */ function producer(label = 'sleep 60') { let settle!: (outcome: TaskOutcome) => void + // A stream producer, so the carrier CAN consume the cursor if it ever calls + // `read()`; `reads` is what proves it never does. + const reads = { count: 0 } const spec = { kind: 'bash' as const, label, run: () => ({ cancel: () => {}, done: new Promise((resolve) => { settle = resolve }), + readOutput: () => { reads.count += 1; return 'stolen output' }, }), } - return { spec, settle: (outcome: TaskOutcome) => { settle(outcome) } } + return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } } } async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> { @@ -204,3 +208,56 @@ describe('session/tasks without the registry', () => { expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false) }) }) + +describe('session/tasks never consumes model output', () => { + it('drives the whole lifecycle without calling the single consuming cursor', async () => { + // `ctx.tasks.read()` consumes the one output cursor, so a carrier read + // silently takes bytes the model's `task_output` will never see. The + // failure is invisible at the call site, which is why this asserts the + // count rather than trusting review. + const { ctx, agent } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-no-read'), payload: {} }, abort.signal) + const collected = collect(stream, 3, abort) + + const p = producer() + const id = ctx.tasks.start({ ...p.spec, owner: agent }) + ctx.tasks.kill(id, agent, 'test') + p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + await collected + + expect(p.reads.count).toBe(0) + }) + + it('reads nothing while minting the subscription baseline either', async () => { + const { ctx, agent } = await harness(true) + const p = producer() + ctx.tasks.start({ ...p.spec, owner: agent }) + + const abort = new AbortController() + const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal) + const [baseline] = await collect(stream, 1, abort) + + expect(baseline?.tasks).toHaveLength(1) + expect(p.reads.count).toBe(0) + }) +}) + +describe('session/tasks baseline for a session born after the stream opened', () => { + it('carries the already-visible unowned set to the new session', async () => { + const { ctx } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-late-session'), payload: {} }, abort.signal) + + // One unowned task exists before the new session is created; the subscribe + // frame clears the client mirror, so the baseline has to follow it. + ctx.tasks.start(producer('visible to every caller').spec) + const created = ctx.sessions.create() + + const frames = await collect(stream, 2, abort) + const forNew = frames.filter(frame => frame.sessionId === created.id) + expect(forNew.at(-1)?.tasks[0]?.label).toBe('visible to every caller') + }) +}) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 8fa9d37599..c8c217c5bd 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1126,7 +1126,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract onTasksChanged(listener: TasksChangedListener): () => void', - jsDoc: '/**\n * Register an effect-scoped observer of visible-set changes. It fires after\n * every commit that changes what {@link list} returns for that owner —\n * registration, the stopping transition, settlement, and owner-disposal\n * removal — so an observer re-reads rather than accumulating deltas.\n *\n * This is not a superset of {@link onTaskDone}: that one delivers the terminal\n * record under first-wins semantics a control surface couples to notice\n * delivery, while this one carries no delivery meaning and marks nothing\n * reported. Listeners are contained and never awaited.\n * @param listener - receives the owner whose visible set changed, or\n * `undefined` when an unowned task changed and every caller\'s set did.\n * @returns disposer that unregisters the listener.\n */', + jsDoc: '/**\n * Register an effect-scoped observer of visible-set changes. It fires after\n * every commit that changes what {@link list} returns for that owner —\n * registration, every stopping transition (including the one teardown\n * performs before it awaits a slow producer), settlement, owner-disposal\n * removal, and the emptying that service disposal commits — so an observer\n * re-reads rather than accumulating deltas.\n *\n * The registration binds to the CALLING fiber, so an observer mounted outside\n * this service still receives the disposal emptying; that is what stops a\n * consumer from retaining rows after the registry unloads.\n *\n * This is not a superset of {@link onTaskDone}: that one delivers the terminal\n * record under first-wins semantics a control surface couples to notice\n * delivery, while this one carries no delivery meaning and marks nothing\n * reported. Listeners are contained and never awaited.\n * @param listener - receives the owner whose visible set changed, or\n * `undefined` when an unowned task changed and every caller\'s set did.\n * @returns disposer that unregisters the listener.\n */', }, { signature: 'abstract attachSurface(name: string): () => void', diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 947ddec004..5b52d90539 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -369,11 +369,14 @@ export class LocalTaskService extends TaskService { const all = [...this.store.values()] this.cancelForTeardown(all, 'tasks service disposed') await Promise.all(all.map(task => task.settled)) + // Distinct owners whose records just disappeared. `onTasksChanged` binds to + // the CALLING fiber (the traceable proxy rebinds `this.ctx`), so a consumer + // mounted outside this service — the api-proxy carrier reads `ctx.get` from + // the mux stream — is still listening here. Without this it keeps the rows + // it last received after a registry reload. + const emptied = new Set(all.map(task => task.owner)) this.store.clear() - // No change notification here: every `onTasksChanged` registration is an - // effect on this service's own fiber, so the listeners are already gone by - // the time service teardown reaches this line. An observer learns the - // registry left through its own disposal, not through a final empty set. + for (const owner of emptied) this.notifyChanged(owner) this.changeListeners.clear() // Detach cross-fiber owner effects after the shared store is quiescent. const ownerCleanups = [...this.ownerCleanups.values()] @@ -392,6 +395,10 @@ export class LocalTaskService extends TaskService { try { task.cancel(reason) task.status = 'stopping' + // Teardown reaches settlement only after the producer releases, which a + // slow stop can defer; announcing the transition here is what keeps an + // observer from showing `running` for that whole window. + this.notifyChanged(task.owner) } catch (error: unknown) { const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}` this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index ebd1e73b52..1fa0323fec 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -853,3 +853,57 @@ describe('LocalTaskService.onTasksChanged', () => { expect(seen).toEqual([1, 2, 2]) }) }) + +describe('LocalTaskService teardown change notifications', () => { + it('announces the stopping transition during owner teardown, before settlement', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'alice') + ctx.agents.register(owner) + const p = producer({ owner }) + const id = ctx.tasks.start(p.spec) + + const statuses: (string | undefined)[] = [] + ctx.tasks.onTasksChanged((changed) => { + statuses.push(changed === undefined ? undefined : ctx.tasks.list(changed)[0]?.status) + }) + + // A slow producer keeps teardown parked between cancel and settlement; + // an observer must not be left showing `running` for that whole window. + const disposal = disposeAgentScope(owner) + await tick() + expect(statuses).toEqual(['stopping']) + + p.settle({ status: 'killed' }) + await disposal + // Settlement, then the removal that empties the visible set. + expect(statuses).toEqual(['stopping', 'killed', undefined]) + expect(ctx.tasks.list(owner)).toEqual([]) + void id + }) + + it('announces the emptied set to a listener registered outside this service (reload safety)', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(LocalTaskService) + ctx.tasks.attachSurface('test-surface') + + // The api-proxy carrier registers from its own stream context, not the + // registry's fiber, so it is still listening when the registry unloads. + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + let settle!: (outcome: TaskOutcome) => void + ctx.tasks.start({ + kind: 'bash', + label: 'sleep 600', + run: () => ({ + cancel() { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + seen.length = 0 + + await fiber.dispose() + // stopping (teardown cancel), settlement, then the final empty set. + expect(seen).toEqual([undefined, undefined, undefined]) + }) +}) diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index da255745ad..628874e038 100644 --- a/packages/tasks/tasks/README.i18n.yaml +++ b/packages/tasks/tasks/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/tasks/tasks/README.md -README.md: 8ad124531daf74f1d44ff46aaaad9b9b860b07c8 -README.zh.md: c1452d4610c65fa66c5ba2f2939b8c501e92bd7a +README.md: 71da4cbd189c3cd2aac51bfb004d2dd774e081c2 +README.zh.md: 283e3dd3aee1c86c856f056156ebe12caff276ae diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 8ad124531d..71da4cbd18 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -12,7 +12,7 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService` - `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported. - `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter. - `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited. -- `onTasksChanged(listener)` observes visible-set changes — registration, the stopping transition, settlement, and owner-disposal removal — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. It is owner-granular because removal is a change no per-task record can express, and it is not a superset of `onTaskDone`: it carries no delivery meaning and marks nothing reported. +- `onTasksChanged(listener)` observes visible-set changes — registration, every stopping transition (teardown's included, before it awaits a slow producer), settlement, owner-disposal removal, and the emptying service disposal commits — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. It is owner-granular because removal is a change no per-task record can express, and it is not a superset of `onTaskDone`: it carries no delivery meaning and marks nothing reported. The registration binds to the calling fiber, so an observer mounted outside the registry still sees the disposal emptying. - `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached. Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal. diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index c1452d4610..283e3dd3ae 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -12,7 +12,7 @@ - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 - `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 - `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。 -- `onTasksChanged(listener)` 观察可见集合的变化——注册、转入 stopping、结算,以及 owner 销毁时的移除——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。 +- `onTasksChanged(listener)` 观察可见集合的变化——注册、每一次转入 stopping(包括 teardown 在等待缓慢生产者之前的那一次)、结算、owner 销毁时的移除,以及服务销毁提交的清空——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。注册绑定的是调用方 fiber,因此挂在注册表之外的观察者仍能收到销毁时的清空。 - `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。 有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。 diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 600f157660..1abc750564 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -134,8 +134,14 @@ export abstract class TaskService extends Service { /** * Register an effect-scoped observer of visible-set changes. It fires after * every commit that changes what {@link list} returns for that owner — - * registration, the stopping transition, settlement, and owner-disposal - * removal — so an observer re-reads rather than accumulating deltas. + * registration, every stopping transition (including the one teardown + * performs before it awaits a slow producer), settlement, owner-disposal + * removal, and the emptying that service disposal commits — so an observer + * re-reads rather than accumulating deltas. + * + * The registration binds to the CALLING fiber, so an observer mounted outside + * this service still receives the disposal emptying; that is what stops a + * consumer from retaining rows after the registry unloads. * * This is not a superset of {@link onTaskDone}: that one delivers the terminal * record under first-wins semantics a control surface couples to notice From 1861a3fc7ce3b26e6ddcf283efbae9b9957c10cf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 15:09:07 +0800 Subject: [PATCH 07/77] feat(fs): add a minimal read_image tool over the attachment and fs seams The model reads a PNG/JPEG/WebP/GIF file, the bytes commit through the durable attachment lifecycle, and the tool result carries the real ImageBlock so the image enters context from the next request onward. FileSystem gains a bounded readBytes primitive (local + E2B providers); registration is conditional on the attachment store, and a strict execution gate refuses routes that do not declare image input, so a text route's durable history stays free of image blocks. llm-replay models may declare inputModalities, letting keyless ACP snapshots pin both the sha256-referenced success and the verbatim refusal. Supersedes the withdrawn route-scoped design of PR #598; the decision record is .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md. --- ...26-08-10-minimal-read-image-tool.i18n.yaml | 6 + .../2026-08-10-minimal-read-image-tool.md | 33 ++ .../2026-08-10-minimal-read-image-tool.zh.md | 33 ++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 10 +- docs/config-catalog.zh.md | 8 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/subsystems/filesystem.i18n.yaml | 4 +- docs/subsystems/filesystem.md | 13 + docs/subsystems/filesystem.zh.md | 13 + docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 25 +- docs/tool-catalog.zh.md | 25 +- .../image-text-route.cordis.snapshot.yml | 39 ++ .../acp-agent/image-text-route.cordis.yml | 27 + examples/acp-agent/image.cordis.snapshot.yml | 41 ++ examples/acp-agent/image.cordis.yml | 27 + examples/acp-agent/tests/acp.snapshot.ts | 26 + .../read-image-text-route/input.json | 14 + .../read-image-text-route/session.jsonl | 26 + .../stdout.expected.jsonl | 4 + .../read-image-text-route/workspace/red.png | Bin 0 -> 69 bytes .../tests/snapshots/read-image/input.json | 14 + .../tests/snapshots/read-image/session.jsonl | 26 + .../read-image/stdout.expected.jsonl | 4 + .../read-image/tool-schemas.expected.json | 543 ++++++++++++++++++ .../snapshots/read-image/workspace/red.png | Bin 0 -> 69 bytes examples/package.json | 7 +- .../tests/workspace-context.spec.ts | 4 + .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/e2b/fs-e2b/src/index.ts | 18 + packages/e2b/fs-e2b/tests/filesystem.spec.ts | 17 + packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 3 +- packages/fs/fs-local/README.zh.md | 3 +- packages/fs/fs-local/src/fsio.ts | 46 ++ packages/fs/fs-local/src/index.ts | 5 + packages/fs/fs-local/tests/filesystem.spec.ts | 37 ++ packages/fs/fs/README.i18n.yaml | 4 +- packages/fs/fs/README.md | 9 +- packages/fs/fs/README.zh.md | 9 +- packages/fs/fs/src/index.ts | 12 + packages/fs/fs/src/types.ts | 1 + packages/fs/fs/tests/service.spec.ts | 17 + packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 38 +- packages/fs/tool-fs/README.zh.md | 38 +- packages/fs/tool-fs/package.json | 2 + packages/fs/tool-fs/src/index.ts | 7 + packages/fs/tool-fs/src/read-image.ts | 231 ++++++++ packages/fs/tool-fs/tests/read-image.spec.ts | 462 +++++++++++++++ packages/fs/tool-fs/tests/tools.spec.ts | 7 + packages/fs/tool-fs/tsconfig.json | 3 + .../tool-cordis/src/api-catalog.ts | 4 + .../skill-local/tests/skill-local.spec.ts | 4 + packages/support/llm-replay/README.i18n.yaml | 4 +- packages/support/llm-replay/README.md | 2 +- packages/support/llm-replay/README.zh.md | 2 +- packages/support/llm-replay/src/index.ts | 7 + .../llm-replay/tests/llm-replay.spec.ts | 6 +- pnpm-lock.yaml | 6 + scripts/gen-cordis-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 35 +- 65 files changed, 1973 insertions(+), 67 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md create mode 100644 examples/acp-agent/image-text-route.cordis.snapshot.yml create mode 100644 examples/acp-agent/image-text-route.cordis.yml create mode 100644 examples/acp-agent/image.cordis.snapshot.yml create mode 100644 examples/acp-agent/image.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/read-image-text-route/input.json create mode 100644 examples/acp-agent/tests/snapshots/read-image-text-route/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image-text-route/workspace/red.png create mode 100644 examples/acp-agent/tests/snapshots/read-image/input.json create mode 100644 examples/acp-agent/tests/snapshots/read-image/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json create mode 100644 examples/acp-agent/tests/snapshots/read-image/workspace/red.png create mode 100644 packages/fs/tool-fs/src/read-image.ts create mode 100644 packages/fs/tool-fs/tests/read-image.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml new file mode 100644 index 0000000000..dcd01fc6d3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.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-minimal-read-image-tool.md +2026-08-10-minimal-read-image-tool.md: a43e53d70e98bac7a50aa6bbabbb1e177237df01 +2026-08-10-minimal-read-image-tool.zh.md: a94e4b296425ad50876b0b45a689442c896a85a1 diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md new file mode 100644 index 0000000000..a43e53d70e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md @@ -0,0 +1,33 @@ +# Agent Note: A minimal read_image tool over existing seams + +Status: implemented + +English | [中文](2026-08-10-minimal-read-image-tool.zh.md) + +## Problem + +The multimodal attachment work gave user uploads a complete durable path — bytes committed to the content-addressed attachment store before the owning `user/message`, an `ImageBlock` carrying only the `sha256:` reference, and the pi-ai route re-reading verified bytes per request — but the model itself had no way to look at an image on disk. `read` rejects binary content by contract, so an agent asked about a screenshot or a rendered chart either failed or shelled out to lossy workarounds. A first standalone attempt (PR #598) solved this together with loop-level route scoping: an `agent/request-ready` extension point publishing exact-model modalities before assembly, per-route schema/guidance visibility, and a reversible `image-placeholder-v1` history projection so text routes could continue over placeholder text. That design worked but coupled a tool to new agent-loop machinery, three new session-log concepts, and per-step registration churn — far more surface than the capability needs. + +## Decision + +Ship the smallest tool that loads an image into the next request's context, entirely over existing seams; the withdrawn PR #598 design is the explicit counter-example this note records. + +- **`read_image` lives in `dsh-tool-fs`** beside `read`/`write`/`edit`. Extension selects the declared PNG/JPEG/WebP/GIF media type; the attachment store's magic-byte and pixel validation stays authoritative. Bytes travel `ctx.fs.stat` → bounded `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed`, and the tool result is the metadata envelope plus a real `ImageBlock` — `ToolResultBlock.content` already admits image blocks, the pi-ai adapter already renders them, and the Web host's model-switch guard already scans tool results, so nothing downstream changes. +- **`FileSystem.readBytes(target, signal, maxBytes)`** is a new required provider primitive: the byte bound lives at the seam so no backend can buffer an unbounded file, with the stat-size short-circuit and a one-byte-past-cap stream guard against post-stat growth (`FS_TOO_LARGE`). +- **Registration is composition-conditional, execution is route-gated.** The tool registers only under `ctx.inject(['attachments'], …)` — no store, no tool. At execution, before any I/O, the strict gate resolves the calling route (latest `request/header` config, falling back to agent options) through `ctx.llm.resolveModelInfo` and requires `image` in `inputModalities`; unknown capability refuses. A refusal is a plain `isError` result, so a text route's durable history never acquires an image block and the session cannot brick its own route. +- **Code Mode forwards the image out-of-band**: a nested dispatch returns the canonical value (execution-local, no image block) and defers a `user`-role context message carrying the envelope and image, so the picture still reaches the next request. +- **llm-replay models may declare `inputModalities`**, which is what lets the two keyless ACP snapshots pin both sides of the gate — the sha256-referenced success on an image-capable replay route and the verbatim refusal on a text-only one. + +## Alternatives considered + +- **PR #598's route-scoped design** (request-ready seam, per-route schema/guidance visibility, reversible history projection) — withdrawn in favor of this note's shape. What it bought: text routes could keep running after images entered history, and the tool disappeared from prompts where it cannot succeed. What it cost: agent-loop changes, three new durable concepts (`agent/request-ready`, `messageProjection`, availability notices), and registration that churned per step. The capability itself — see an image on the next request — never needed any of it. If per-route projection becomes a real requirement, that PR's history is the reference implementation. +- **`agent.inject()` instead of the image-bearing tool result** — routes the image around the tool result as a separate injected user message. Rejected: the image *is* the tool's result; splitting them adds a second logged message with no gain, and the tool-result path already works end to end. +- **Magic-byte sniffing instead of extension declaration** — sniffing duplicates detection the attachment store already owns (sharp-backed, authoritative). The extension is only a *declaration*; a mismatch fails closed with a rename remedy rather than being silently accepted, which also keeps the model's mental map (file name ↔ content) honest. +- **Registering unconditionally and failing on a missing store** — rejected; a deployment without an attachment store cannot ever satisfy the tool, so its schema would be a standing lie. The route gate, by contrast, is per-call state and correctly lives at the execution boundary. + +## Consequences + +- A text-only route refuses instead of degrading: no placeholder projection means no delegated-viewing story here — that is deliberately the next PR (subagent image readback rebuilt on the current subagent seams). +- The route gate races a concurrent model switch; the Web host's image-aware switch guard covers its surface, and other front doors own their equivalent. Recorded as a tool-fs Known Limitation. +- Repeated image results accumulate request-token cost until compaction; content addressing deduplicates bytes only. +- The tool-result card renders the durable reference, not pixels; inline preview is deferred to the UI packages. diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md new file mode 100644 index 0000000000..a94e4b2964 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 基于既有 seam 的最小 read_image 工具 + +Status: implemented + +[English](2026-08-10-minimal-read-image-tool.md) | 中文 + +## 问题 + +多模态附件工作为用户上传建立了完整的持久路径:字节在所属 `user/message` 之前提交到内容寻址的附件存储,`ImageBlock` 只携带 `sha256:` 引用,pi-ai 路由在每次请求时重新读取并校验字节。但模型自己没有查看磁盘图像的手段。`read` 按约定拒绝二进制内容,因此被问到截图或渲染图表的 agent 要么失败,要么退到有损的变通做法。第一次独立尝试(PR #598)把这个问题与循环级路由作用域一起解决:新增在组装前发布确切模型模态的 `agent/request-ready` 扩展点、按路由控制 schema/指导可见性,以及可逆的 `image-placeholder-v1` 历史投影让文本路由能在占位符上继续。该设计可行,但让一个工具耦合了新的 agent-loop 机制、三个新的会话日志概念和每步的注册变动,远超这项能力本身的需要。 + +## 决定 + +只交付能把图像载入下一次请求上下文的最小工具,完全建立在既有 seam 之上;撤回的 PR #598 设计是本记录明确保留的反例。 + +- **`read_image` 放在 `dsh-tool-fs`**,与 `read`/`write`/`edit` 并列。扩展名选择声明的 PNG/JPEG/WebP/GIF 媒体类型;附件存储的魔数与像素校验保持权威。字节沿 `ctx.fs.stat` → 有界 `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed` 流动,工具结果是元数据信封加真正的 `ImageBlock`——`ToolResultBlock.content` 本就允许图像块,pi-ai 适配器本就会渲染它们,Web 宿主的模型切换防护本就会扫描工具结果,下游无需任何改动。 +- **`FileSystem.readBytes(target, signal, maxBytes)`** 是新的必备提供方原语:字节上限放在 seam 上,任何后端都无法无界缓冲文件;stat 大小先短路,随后的流最多多读一个字节以防 stat 之后的增长(`FS_TOO_LARGE`)。 +- **注册随组合条件挂载,执行按路由门禁。** 工具只在 `ctx.inject(['attachments'], …)` 作用域内注册——没有存储就没有工具。执行时在任何 I/O 之前,严格门禁通过 `ctx.llm.resolveModelInfo` 解析调用路由(最新 `request/header` 配置,缺失时回退到 agent 选项),要求 `inputModalities` 包含 `image`;能力未知即拒绝。拒绝是普通的 `isError` 结果,因此文本路由的持久历史绝不会出现图像块,会话不会毁掉自己的路由。 +- **Code Mode 以带外方式转发图像**:嵌套分派返回规范值(仅限本次执行,不含图像块),并延迟提交一条携带信封和图像的 `user` 角色上下文消息,图片仍会到达下一次请求。 +- **llm-replay 模型可以声明 `inputModalities`**,这正是两个 keyless ACP 快照能钉住门禁两侧的原因:图像路由上以 sha256 引用的成功结果,和纯文本路由上逐字的拒绝。 + +## 考虑过的替代方案 + +- **PR #598 的路由作用域设计**(request-ready 扩展点、按路由的 schema/指导可见性、可逆历史投影)——被本记录的形态取代后撤回。它换来的是:图像进入历史后文本路由仍能运行,工具在注定失败的提示词里消失。它付出的是:改动 agent-loop、三个新的持久概念(`agent/request-ready`、`messageProjection`、可用性通知)和每步变动的注册。而这项能力本身——下一次请求看到图像——从不需要这些。如果按路由投影将来成为真实需求,该 PR 的历史就是参考实现。 +- **用 `agent.inject()` 代替带图像的工具结果**——把图像绕过工具结果,作为单独注入的用户消息。拒绝:图像就是工具的结果;拆开只会多一条无收益的日志消息,而工具结果路径本就端到端可用。 +- **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp,权威)。扩展名只是声明;不匹配时按改名修复提示失败关闭,而不是被静默接受,这也让模型对文件名与内容的对应保持诚实。 +- **无条件注册、缺存储时执行报错**——拒绝;没有附件存储的部署永远无法满足该工具,其 schema 会是常态谎言。相反,路由门禁是逐调用状态,正确的位置就是执行边界。 + +## 后果 + +- 纯文本路由得到拒绝而不是降级:没有占位符投影意味着这里没有委托查看的方案——那有意留给下一个 PR(基于当前 subagent seam 重建的 subagent image readback)。 +- 路由门禁与并发模型切换存在竞态;Web 宿主的图像感知切换防护覆盖其表面,其他前端拥有各自的等价防护。已记入 tool-fs 的已知限制。 +- 重复的图像结果在压缩之前持续累积请求 token 成本;内容寻址只去重字节。 +- 工具结果卡片渲染持久引用而非像素;内嵌预览延后到 UI 包处理。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ae1ec3bb37..340901034d 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: 1140b0bec68c0a883ec5020cb204bead9739bbf2 -config-catalog.zh.md: 220d66cadda37d388b759a1ab9c0cc3691a55ec9 +config-catalog.md: 7cfc76ecd7a71f30ea3725fbc442658b14c20c4a +config-catalog.zh.md: fb5c360364a2d4d22ea0d1582f51473db159d059 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1140b0bec6..7cfc76ecd7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -528,7 +528,7 @@ export interface Config { } ``` -Source: [`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-fs-sandbox` @@ -983,12 +983,14 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** Optional declared input modalities, so a scenario can exercise capability gates (e.g. image-capable `read_image`). */ + inputModalities?: readonly ModelModality[] } ``` -Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:744`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:751`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -2079,7 +2081,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs/src/index.ts:24`](../packages/fs/tool-fs/src/index.ts) +Source: [`packages/fs/tool-fs/src/index.ts:25`](../packages/fs/tool-fs/src/index.ts) ## `@deepseek-ai/dsh-tool-fs-search` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 220d66cadd..fb5c360364 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -530,7 +530,7 @@ export interface Config { } ``` -来源:[`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts) +来源:[`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-fs-sandbox` @@ -985,12 +985,14 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** Optional declared input modalities, so a scenario can exercise capability gates (e.g. image-capable `read_image`). */ + inputModalities?: readonly ModelModality[] } ``` -依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/support/llm-replay/src/index.ts:744`](../packages/support/llm-replay/src/index.ts) +来源:[`packages/support/llm-replay/src/index.ts:751`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index caf3a7b7e2..4eef7e146d 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: e75b98de7faf74388fdf95f0fe78794cda4a91fe -module-graph.zh.md: e3e01c8e63f35db422f5bf0e669ec2024fff304b +module-graph.md: 13cc178f99533e47e29ac5764833ffd2fc2a18db +module-graph.zh.md: 6754581ede7e9c230f916870e20f9054a9fa3d5b diff --git a/docs/module-graph.md b/docs/module-graph.md index e75b98de7f..13cc178f99 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -775,6 +775,7 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -1361,7 +1362,7 @@ flowchart TD | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e3e01c8e63..6754581ede 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -777,6 +777,7 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -1363,7 +1364,7 @@ flowchart TD | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index fac61bde63..ba3fb6390b 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.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/filesystem.md -filesystem.md: e0edad514b0c4d9108b18cc0b472600b29024c51 -filesystem.zh.md: 81c2d87b6a96740c7a99449a27c16331f5a0645a +filesystem.md: 2013d2f2c9be4ac2cc16c1b0cf3685e961919a05 +filesystem.zh.md: 86e8cf3f2cf8c77ab4b53a438d86a980e13f4ac5 diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index e0edad514b..2013d2f2c9 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -255,6 +255,7 @@ type FsErrorCode = | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' + | 'FS_TOO_LARGE' | 'FS_PERMISSION_DENIED' | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' @@ -372,6 +373,18 @@ abstract readText(target: FsTarget, signal?: AbortSignal): Promise */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +/** + * Read the whole regular file as raw bytes with no decoding or binary + * rejection. The bound lives at this seam so a backend can never buffer an + * unbounded file: a target known or discovered to exceed `maxBytes` fails + * with `FS_TOO_LARGE` instead of returning a truncated result. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @param maxBytes - inclusive byte cap on the complete content. + * @returns the full raw content, at most `maxBytes` long. + */ +abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise + /** * List direct children of a directory in stable name order. Returns resolved * child targets plus cheap metadata only; never reads file contents. diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 81c2d87b6a..86e8cf3f2c 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -255,6 +255,7 @@ type FsErrorCode = | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' + | 'FS_TOO_LARGE' | 'FS_PERMISSION_DENIED' | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' @@ -372,6 +373,18 @@ abstract readText(target: FsTarget, signal?: AbortSignal): Promise */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +/** + * Read the whole regular file as raw bytes with no decoding or binary + * rejection. The bound lives at this seam so a backend can never buffer an + * unbounded file: a target known or discovered to exceed `maxBytes` fails + * with `FS_TOO_LARGE` instead of returning a truncated result. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @param maxBytes - inclusive byte cap on the complete content. + * @returns the full raw content, at most `maxBytes` long. + */ +abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise + /** * List direct children of a directory in stable name order. Returns resolved * child targets plus cheap metadata only; never reads file contents. diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 3e74bbc3e6..888d47ff52 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-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/tool-catalog.md -tool-catalog.md: 58267e208d919e7fa817991207a44ac0864fa00e -tool-catalog.zh.md: 1eda3506644bae6da56895c366a56b566536ed62 +tool-catalog.md: af8167569e2ac4cc24f0d80c8c0c8a1d09f2237a +tool-catalog.zh.md: 053a0e1e4a0db0563f5fb0e1b34b735fdb9832ad diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 58267e208d..af8167569e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -23,7 +23,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | -| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful mutation`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | +| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (read_image registration)`, `ctx.llm + an image-capable route (read_image execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | @@ -485,6 +485,27 @@ Read a UTF-8 text file and return line-numbered content. Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) +### `read_image` + +Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input. + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] +} +``` + +Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) + ### `write` Create or fully replace a UTF-8 text file. @@ -511,7 +532,7 @@ Create or fully replace a UTF-8 text file. Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. +The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. ## `@deepseek-ai/dsh-tool-fs-search` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 1eda350664..053a0e1e4a 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -25,7 +25,7 @@ | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`、`cordis_mount`、`cordis_unmount` | `ctx.tools` | `tool/call`、`tool/result`、`process-local temporary Plugin lifecycle` | - | 不在任何随产品发布的树中,需要有意选择启用;临时 Plugin 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。由 cordis_mount 创建的插件在卸载或 DSH 重启之前可以注册**额外的**模型可见工具;发生这类工具集变更时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.pty`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | -| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful mutation`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件,上述工具 schema 都完全相同。 | +| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (read_image registration)`、`ctx.llm + an image-capable route (read_image execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`、`terminal_list`、`terminal_open`、`terminal_read`、`terminal_send`、`terminal_signal` | `ctx.tools`、`ctx.pty`、`ctx.systemPrompt`、`ctx.tasks at call time for run_in_background` | `tool/call`、`tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash/文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.tasks`;schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 | | `@deepseek-ai/dsh-tool-goal` | `create_goal`、`get_goal`、`update_goal` | `ctx.tools`、`ctx.agents`、`ctx.goals`、`ctx.systemPrompt`、`a calling Agent in an authorized open turn` | `tool/call`、`goal/change for mutations`、`tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限;complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 | @@ -489,6 +489,27 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) +### `read_image` + +读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。要求当前模型接受图像输入。 + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] +} +``` + +来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) + ### `write` 创建或完全替换 UTF-8 文本文件。 @@ -515,7 +536,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件,上述工具 schema 都完全相同。 +先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 ## `@deepseek-ai/dsh-tool-fs-search` diff --git a/examples/acp-agent/image-text-route.cordis.snapshot.yml b/examples/acp-agent/image-text-route.cordis.snapshot.yml new file mode 100644 index 0000000000..af7a8614ed --- /dev/null +++ b/examples/acp-agent/image-text-route.cordis.snapshot.yml @@ -0,0 +1,39 @@ +# Keyless replay for the read-image refusal scenario: identical to the +# image.cordis.snapshot.yml overlay except the replay catalog leaves flash +# text-only, so the strict read_image gate refuses and no image ever enters +# the durable log. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + inputModalities: [text] + - id: deepseek-v4-pro + inputModalities: [text] diff --git a/examples/acp-agent/image-text-route.cordis.yml b/examples/acp-agent/image-text-route.cordis.yml new file mode 100644 index 0000000000..96ef510ab3 --- /dev/null +++ b/examples/acp-agent/image-text-route.cordis.yml @@ -0,0 +1,27 @@ +# Text-route image overlay: the attachment store registers read_image, but the +# strict execution gate refuses on a route that does not declare image input, +# so a text-only deployment keeps its durable history text-clean. The app +# config is restated to re-pin `deepseek-v4-flash` (base ships pro; the +# authored fixture and the pinned header class are flash), because a config +# patch replaces the whole app config. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' diff --git a/examples/acp-agent/image.cordis.snapshot.yml b/examples/acp-agent/image.cordis.snapshot.yml new file mode 100644 index 0000000000..e4f8aecdb0 --- /dev/null +++ b/examples/acp-agent/image.cordis.snapshot.yml @@ -0,0 +1,41 @@ +# Keyless replay for the read-image success scenario. Include patches cannot +# target entries behind a nested include, so this restates the replay overlay +# directly over the base cordis.yml (the fs.cordis.snapshot.yml pattern) and +# re-pins the recorded flash model. The replay catalog declares image input on +# flash, so the strict read_image gate accepts the route and the tool result +# carries the durable image block; the live DeepSeek route cannot record this. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + inputModalities: [text, image] + - id: deepseek-v4-pro + inputModalities: [text] diff --git a/examples/acp-agent/image.cordis.yml b/examples/acp-agent/image.cordis.yml new file mode 100644 index 0000000000..d306afe735 --- /dev/null +++ b/examples/acp-agent/image.cordis.yml @@ -0,0 +1,27 @@ +# Image-scenario overlay: adds the durable attachment store the read_image tool +# commits through. The store resolves its root from $DSH_HOME, which the +# snapshot harness scopes per run, so the overlay itself carries no paths. The +# app config is restated to re-pin `deepseek-v4-flash` (base ships pro; the +# authored fixture and the pinned header class are flash), because a config +# patch replaces the whole app config. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..fcce9bd99d 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -38,6 +38,8 @@ const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cor const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url)) +const IMAGE_CONFIG = fileURLToPath(new URL('../image.cordis.yml', import.meta.url)) +const IMAGE_TEXT_ROUTE_CONFIG = fileURLToPath(new URL('../image-text-route.cordis.yml', import.meta.url)) const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const CHILD_QUESTION_CONFIG = fileURLToPath(new URL('../child-question.cordis.yml', import.meta.url)) @@ -153,6 +155,30 @@ const SCENARIOS: Scenario[] = [ configPath: SESSION_QUERY_CONFIG, posixOnly: true, }, + // Authored keyless replays through the assembled app: the replay catalog + // declares flash image-capable (success) or text-only (refusal), and the + // real read_image tool executes against the workspace fixture and the real + // attachment store. Both boot the same composed header (the tool registers + // with the attachment store, independent of route), so they share one class. + { + name: 'read-image', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'image', + // The overlay adds no prompt section (read_image carries no guidance), so + // the composed system prompt is byte-identical to the default class; only + // the tool-schema sidecar is class-specific. + systemPromptSource: 'text-turn', + configPath: IMAGE_CONFIG, + }, + { + name: 'read-image-text-route', + hasModelTurn: true, + recorded: false, + headerClass: 'image', + configPath: IMAGE_TEXT_ROUTE_CONFIG, + }, { name: 'pty-tools', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/read-image-text-route/input.json b/examples/acp-agent/tests/snapshots/read-image-text-route/input.json new file mode 100644 index 0000000000..551bfee00a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-text-route/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use read_image on red.png in the current directory. If the tool refuses because the current model is text-only, reply with exactly the single word UNAVAILABLE." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/read-image-text-route/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-text-route/session.jsonl new file mode 100644 index 0000000000..3e32a50a32 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-text-route/session.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1783951000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on red.png in the current directory. If the tool refuses because the current model is text-only, reply with exactly the single word UNAVAILABLE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}} +{"type":"turn/start","seq":1,"time":1783951000002,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1783951000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783951000003,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on red.png in the current directory. If the tool refuses because the current model is text-only, reply with exactly the single word UNAVAILABLE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786344284632,"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":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786344284632,"data":{"title":"Use read_image on red.png in","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786344284632,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786344284633,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786344284637,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1786344284637,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1786344284637,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1786344284638,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1786344284638,"data":{"turn":1,"step":1,"callId":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} +{"type":"tool/result","seq":15,"time":1786344284643,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-refused"},"content":[{"type":"tool-result","toolCallId":"read-image-refused","content":[{"type":"text","text":"Error: cannot read \"red.png\" as an image: model \"deepseek-v4-flash\" does not declare image input; switch to an image-capable model to read images"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1786344284643,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1786344284648,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1783951000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":1786344284652,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNAVAILABLE"}}}} +{"type":"assistant/chunk","seq":20,"time":1786344284652,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":21,"time":1786344284652,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1786344284652,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNAVAILABLE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1786344284653,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1786344284653,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl new file mode 100644 index 0000000000..f93f99ce97 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"UNAVAILABLE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image-text-route/workspace/red.png b/examples/acp-agent/tests/snapshots/read-image-text-route/workspace/red.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/examples/acp-agent/tests/snapshots/read-image/input.json b/examples/acp-agent/tests/snapshots/read-image/input.json new file mode 100644 index 0000000000..603d2ad4ac --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/read-image/session.jsonl b/examples/acp-agent/tests/snapshots/read-image/session.jsonl new file mode 100644 index 0000000000..6acde5a0ff --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/session.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1783951000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}} +{"type":"turn/start","seq":1,"time":1783951000002,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1783951000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783951000003,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786344283033,"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":"eecd1df6-153c-4a34-b198-42bfc9f9701e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786344283033,"data":{"title":"Use read_image to look at","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"41e9fb55-6edb-419d-b76c-554daa5a1c5d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1786344283039,"data":{"turn":1,"step":1,"callId":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} +{"type":"tool/result","seq":15,"time":1786344283069,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-call"},"content":[{"type":"tool-result","toolCallId":"read-image-call","content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"0b5779fc-523e-4275-9a32-8eb5e39f521e"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1786344283069,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1786344283075,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1783951000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":1786344283078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":20,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":21,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73a87a50-8e0b-42af-8c54-d9b6fbe375f1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1786344283079,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1786344283079,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json new file mode 100644 index 0000000000..8da6396169 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json @@ -0,0 +1,543 @@ +{ + "initial": [ + { + "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`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "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." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "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." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "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": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "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": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "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": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "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." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/read-image/workspace/red.png b/examples/acp-agent/tests/snapshots/read-image/workspace/red.png new file mode 100644 index 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/examples/package.json b/examples/package.json index 48ac39f5ca..dce26b87da 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-attachment-local": "workspace:*", "@deepseek-ai/dsh-bash": "workspace:*", "@deepseek-ai/dsh-bash-env": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", @@ -27,8 +28,8 @@ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-e2b": "workspace:*", - "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-e2b": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:*", @@ -77,8 +78,8 @@ "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", - "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-subprocess-e2b": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 71073264cd..750e692dd5 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -117,6 +117,10 @@ class RecordingFileSystem extends FileSystem { return this.entries.get(target.targetKey)?.content ?? '' } + override async readBytes(_target: FsTarget, _signal: AbortSignal | undefined, _maxBytes: number): Promise { + throw new Error('not needed in workspace-context tests') + } + override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { if (signal !== undefined) this.signals.push(signal) signal?.throwIfAborted() diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 2e59ffe32e..dcd9ca01c6 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index b26338ffa4..e8921909d4 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -227,6 +227,24 @@ export class E2BFileSystem extends FileSystem { } } + override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise { + const sandbox = await this.ctx.e2b.getSandbox() + await this.requireRegular(target, signal) + let bytes: Uint8Array + try { + // The E2B files API returns the whole object; the remote sandbox owns + // that buffering, so the seam bound is enforced on the complete result. + bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) }) + } catch (error: unknown) { + throw mapError(error, 'read', target.displayPath, signal) + } + assertNotAborted(signal, 'read') + if (bytes.byteLength > maxBytes) { + throw new FsError(`cannot read "${target.displayPath}": ${bytes.byteLength} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + } + return bytes + } + override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { const sandbox = await this.ctx.e2b.getSandbox() await this.requireRegular(target, signal) diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 6ee3df6543..44049b7972 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -471,6 +471,23 @@ describe('E2BFileSystem identity, metadata, and reads', () => { await expectCode(fs.streamText(raced), 'FS_NOT_FOUND') }) + it('readBytes returns raw content, enforces the byte cap, and maps failures', async () => { + const remote = new FakeRemote() + remote.file('/workspace/img.bin', [0x89, 0, 0xff, 0x47]) + remote.dir('/workspace/directory') + const { fs } = await setup(remote) + const target = await fs.resolve('img.bin') + expect(Array.from(await fs.readBytes(target, undefined, 4))).toEqual([0x89, 0, 0xff, 0x47]) + await expectCode(fs.readBytes(target, undefined, 3), 'FS_TOO_LARGE') + await expectCode(fs.readBytes(await fs.resolve('missing'), undefined, 4), 'FS_NOT_FOUND') + await expectCode(fs.readBytes(await fs.resolve('directory'), undefined, 4), 'FS_NOT_REGULAR_FILE') + + const live = new AbortController() + expect((await fs.readBytes(target, live.signal, 4)).byteLength).toBe(4) + remote.nextReadError = new DOMException('aborted', 'AbortError') + await expectCode(fs.readBytes(target, undefined, 4), 'FS_ABORTED') + }) + it('honors aborts before and during remote reads', async () => { const remote = new FakeRemote() remote.file('/workspace/a', 'a') diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index ef76470394..b5fbf869ab 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-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/fs/fs-local/README.md -README.md: 7b993fa123d13833313ecf3f78c64e466b960d5d -README.zh.md: 428719137f988395b76513eab2c3f76ce4f331e5 +README.md: 4b65bc3c5a5b31a8b4f96ec86e2239c2da680897 +README.zh.md: 45d9646f89623d2de106cd8e1dab38941c912977 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 7b993fa123..4b65bc3c5a 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **local-filesystem implementation** of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eleven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. +The **local-filesystem implementation** of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)). Backs the twelve `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. ```ts ignore-check import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' @@ -18,6 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`. - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing. +- **`readBytes`** — raw whole-file bytes with no decoding or binary rejection (the `read_image` tool validates content through the attachment service). The required byte cap short-circuits on the stat size before any content I/O; the subsequent stream reads at most one byte beyond the cap, so a file growing after stat still fails `FS_TOO_LARGE` without unbounded buffering. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. - **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a regular file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`, while a non-regular path entry is preserved and rejected with `FS_NOT_REGULAR_FILE`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index 428719137f..45d9646f89 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十一个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。 +`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十二个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。 ```ts ignore-check import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' @@ -18,6 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **执行世界坐标**:`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。 - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。 +- **`readBytes`**:按原始字节读取整个文件,不做解码或二进制拒绝(`read_image` 工具通过附件服务校验内容)。必填的字节上限在任何内容 I/O 之前先按 stat 大小短路;随后的流最多多读一个字节,因此 stat 之后增长的文件仍会以 `FS_TOO_LARGE` 失败,不会无界缓冲。 - **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。 - **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。 - **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。 diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 24ad9b929f..23bd5215db 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -97,6 +97,8 @@ export interface FsIoInternals { removeStagingDir?: (stagingDir: string) => Promise /** Test hook after the temp file is written/synced but before final chmod+publication. */ inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise + /** Test hook after raw-read stat preflight and before bounded content I/O. */ + inspectReadBytesAfterStat?: (target: LocalTarget) => void | Promise } /** A resolved local path: the absolute path shown to callers and its realpath identity. */ @@ -379,6 +381,50 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal): return decodeUtf8(raw, 'read', target.displayPath) } +/** + * Read a whole regular file as raw bytes with no decoding or binary rejection. + * `maxBytes` bounds the complete content: the stat size short-circuits an + * oversized file before any content I/O, and the stream reads at most one byte + * beyond the cap so a file growing after stat cannot cause unbounded buffering. + * @param target - the resolved file to read. + * @param signal - aborts the read (`FS_ABORTED`). + * @param maxBytes - inclusive byte cap on the complete content (`FS_TOO_LARGE`). + * @param internals - test seam for a deterministic post-stat growth race. + * @returns the full raw content, at most `maxBytes` long. + */ +export async function readWholeBytes( + target: LocalTarget, + signal: AbortSignal | undefined, + maxBytes: number, + internals: FsIoInternals = {}, +): Promise { + const info = await statRegularFile(target, 'read', signal) + if (info.size > maxBytes) { + throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + } + await internals.inspectReadBytesAfterStat?.(target) + const stream = createReadStream(target.targetKey, { + end: maxBytes, + ...signal ? { signal } : {}, + }) + const chunks: Buffer[] = [] + let bytes = 0 + try { + for await (const chunk of stream as AsyncIterable) { + bytes += chunk.length + if (bytes > maxBytes) { + throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + } + chunks.push(chunk) + } + } catch (error: unknown) { + /* v8 ignore next 2 -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */ + if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED') + throw error + } + return Buffer.concat(chunks, bytes) +} + /** * Stream a whole regular UTF-8 text file as decoded text chunks. Same text * semantics as {@link readWholeText} (regular-file check, binary/NUL rejection, diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 9cd4be41b8..d42f508863 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -27,6 +27,7 @@ import { probeNoFollow, readForEdit, readTextForDiff, + readWholeBytes, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -129,6 +130,10 @@ export class LocalFileSystem extends FileSystem { return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)) } + override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise { + return readWholeBytes({ displayPath: target.displayPath, targetKey: target.targetKey }, signal, maxBytes, this.internals) + } + override async listDir(target: FsTarget, signal?: AbortSignal): Promise { const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal) return entries.map(entry => ({ diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index f6736ff9c3..930300e390 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -238,6 +238,43 @@ describe('readText / streamText', () => { }) }) +describe('readBytes', () => { + it('reads raw bytes without decoding or NUL rejection', async () => { + const raw = Buffer.from([0x68, 0x00, 0x69, 0xff]) + await writeFile(join(dir, 'a.bin'), raw) + expect(Buffer.from(await fs.readBytes(await fs.resolve('a.bin'), undefined, raw.length))).toEqual(raw) + }) + + it('accepts a file exactly at maxBytes and rejects one past it', async () => { + await writeFile(join(dir, 'a.bin'), Buffer.alloc(4, 1)) + const target = await fs.resolve('a.bin') + expect((await fs.readBytes(target, undefined, 4)).length).toBe(4) + await expect(fs.readBytes(target, undefined, 3)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' }) + }) + + it('bounds content I/O when a file grows after stat preflight', async () => { + await writeFile(join(dir, 'a.bin'), Buffer.alloc(4, 1)) + const target = await fs.resolve('a.bin') + fs.internals.inspectReadBytesAfterStat = () => writeFile(join(dir, 'a.bin'), Buffer.alloc(1024 * 1024, 2)) + + await expect(fs.readBytes(target, undefined, 4)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' }) + }) + + it('rejects a missing file and a directory', async () => { + await expect(fs.readBytes(await fs.resolve('nope'), undefined, 1024)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + await expect(fs.readBytes(await fs.resolve('.'), undefined, 1024)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('reads under a live signal and rejects an already-aborted one with FS_ABORTED', async () => { + await writeFile(join(dir, 'a.bin'), 'data') + const live = new AbortController() + expect((await fs.readBytes(await fs.resolve('a.bin'), live.signal, 1024)).length).toBe(4) + const controller = new AbortController() + controller.abort() + await expect(fs.readBytes(await fs.resolve('a.bin'), controller.signal, 1024)).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) +}) + describe('listDir', () => { it('lists files and directories in stable name order with resolved child targets', async () => { await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true }) diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index c13921729a..9eb2da496a 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/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/fs/fs/README.md -README.md: 62d3febde82e013ace054a9e6242147c1756b0d1 -README.zh.md: 137c1e8da1014bf7dda7c4bf2e667aca6d2f51b5 +README.md: 4e3a37b997c9d377ebb3795f61f9070a8a9c38ea +README.zh.md: 50551d3752acf4ffc12ae40c711c9289fd4d87d7 diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 62d3febde8..4e3a37b997 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. +The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. This package owns the Service Definition and provider contract layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): @@ -17,7 +17,7 @@ This package owns the Service Definition and provider contract layer of the four ## Service API (`ctx.fs`) -A backend subclasses `FileSystem` and implements eleven primitives. +A backend subclasses `FileSystem` and implements twelve primitives. | Member | Semantics | |---|---| @@ -29,6 +29,7 @@ A backend subclasses `FileSystem` and implements eleven primitives. | `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. | | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here); consumers that need a byte ceiling enforce it while consuming the stream. | +| `readBytes(target, signal, maxBytes)` | Read a complete regular file as raw bytes with no decoding or binary rejection. `maxBytes` is required and bounds the complete content at this seam: a known or discovered overflow fails with `FS_TOO_LARGE` instead of truncating or buffering without a bound. | | `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. | | `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. `createIfAbsent` must perform a no-replace publication so a creator racing the initial probe is preserved. | | `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. | @@ -59,7 +60,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md). -- **Eleven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). +- **Text-only mutations by contract** — text reads and both mutations reject binary/non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` is the one raw-byte primitive, and binary-safe mutations remain a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md). +- **Twelve primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). - **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)). - **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend. diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index 137c1e8da1..50551d3752 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 +**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、有界读取原始字节、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 本包是四层文件系统栈中的提供方约定层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): @@ -17,7 +17,7 @@ ## 服务 API(`ctx.fs`) -后端继承 `FileSystem` 并实现十一个原语。 +后端继承 `FileSystem` 并实现十二个原语。 | 成员 | 语义 | |---|---| @@ -29,6 +29,7 @@ | `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库所有的符号链接进入目标前拒绝它。 | | `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 | | `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责);需要字节上限的消费方在消费流时执行该上限。 | +| `readBytes(target, signal, maxBytes)` | 把完整普通文件按原始字节读出,不做解码或二进制拒绝。`maxBytes` 为必填,在该 seam 上限制完整内容:已知或读取中发现的超限以 `FS_TOO_LARGE` 失败,而不是截断或无界缓冲。 | | `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列表失败。 | | `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent`(`createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。`createIfAbsent` 必须以不替换的方式发布,使初始探测后抢先创建的文件得到保留。 | | `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 | @@ -59,7 +60,7 @@ ## 已知限制与延期工作 -- **约定只支持文本**:后端以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。 -- **只有十一个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 +- **变更操作约定只支持文本**:文本读取和两个变更操作都以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 是唯一的原始字节原语,二进制安全的变更操作仍是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。 +- **只有十二个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 - **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.md))。 - **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。 diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 0be94dccc4..44558fa016 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -186,6 +186,18 @@ export abstract class FileSystem extends Service { */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> + /** + * Read the whole regular file as raw bytes with no decoding or binary + * rejection. The bound lives at this seam so a backend can never buffer an + * unbounded file: a target known or discovered to exceed `maxBytes` fails + * with `FS_TOO_LARGE` instead of returning a truncated result. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @param maxBytes - inclusive byte cap on the complete content. + * @returns the full raw content, at most `maxBytes` long. + */ + abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise + /** * List direct children of a directory in stable name order. Returns resolved * child targets plus cheap metadata only; never reads file contents. diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index e842d0d574..6f9fc2a4cc 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -176,6 +176,7 @@ export type FsErrorCode = | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' + | 'FS_TOO_LARGE' | 'FS_PERMISSION_DENIED' | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 2340871304..d2c1787aed 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -50,6 +50,13 @@ class FakeFileSystem extends FileSystem { const content = await this.readText(target) return (async function* () { yield content })() } + override async readBytes(target: FsTarget, _signal: AbortSignal | undefined, maxBytes: number): Promise { + const bytes = new TextEncoder().encode(await this.readText(target)) + if (bytes.length > maxBytes) { + throw new FsError(`too large: ${target.displayPath}`, 'FS_TOO_LARGE') + } + return bytes + } override async listDir(target: FsTarget): Promise { if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY') return [ @@ -112,6 +119,16 @@ describe('FileSystem provider seam', () => { expect(streamed).toBe(await fs.readText(target)) }) + it('readBytes returns raw content and enforces the byte cap with FS_TOO_LARGE', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + fs.files.set('a.bin', 'hi') + const target = await fs.resolve('a.bin') + expect(await fs.readBytes(target, undefined, 2)).toEqual(new TextEncoder().encode('hi')) + await expect(fs.readBytes(target, undefined, 1)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' }) + }) + it('listDir returns child entry targets without reading file content', async () => { const ctx = new Context() await ctx.plugin(FakeFileSystem) diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 96785be516..b83cc55426 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: 27b53aca50f470fe9ead4da27d87328264440ff7 -README.zh.md: 5bcf9c471d933702c46d50c56c9539cb9eede3ca +README.md: e295ad63902cbae245229fa86b780aeb59de808d +README.zh.md: be87bb18a1c49654d07977b5c8e2577188517077 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 27b53aca50..e295ad6390 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -2,17 +2,20 @@ English | [中文](README.zh.md) -The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations. +The **model-facing filesystem tools** — `read`, `read_image`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations. ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) -await ctx.plugin(ToolFs) // this package — registers read/write/edit +await ctx.plugin(LocalAttachmentStore, { dshHome }) // optional — enables durable read_image results +await ctx.plugin(ToolFs) // this package — read/write/edit, plus read_image with attachments ``` `@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. +`read_image` registers only while a durable `ctx.attachments` service is mounted — without one the deployment cannot commit image bytes, so the tool never appears. Execution additionally requires the exact routed model to declare `image` input (resolved through `ctx.llm.resolveModelInfo` from the session's latest request header, falling back to agent options); an unknown or text-only route gets a refusal result before any filesystem I/O, so a text route's durable history stays free of image blocks. + ## Config All keys are optional; the defaults are the shipped read caps. @@ -29,18 +32,20 @@ All keys are optional; the defaults are the shipped read caps. | Tool | Arguments | Behavior | |---|---|---| | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). | +| `read_image` | `file_path` | Reads a PNG/JPEG/WebP/GIF file through the bounded byte seam, persists it through `ctx.attachments.saveImage`, and returns an image block beside a small metadata envelope. It succeeds only when the exact routed model declares image input. | | `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | | `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | Field names are snake_case to match Claude Code and existing harness tool schemas. -Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted. +Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name? } }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted. ## The tool is the executor; policy is an event gate The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then: - **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.) +- **read_image** — validates the argument, extension, attachment availability, deployment media types, and the image-capable route before any I/O; then one `ctx.fs.stat`, a bounded `ctx.fs.readBytes` capped at `imageLimits.maxImageBytes`, `attachments.saveImage` (content-addressed, so the image block references a durably committed object by the time `tool/result` is appended), and finally `fs/observed`. (1 stat.) - **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.) - **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.) @@ -50,11 +55,11 @@ When `ctx.fs.sandboxMode` reports confinement, write/edit advertise `sandbox_per ## `fs/observed` is fire-and-forget -`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. +`fs/observed` fires AFTER the read/read_image/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. `read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). -The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. +The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`read-image.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. ## Model Experience @@ -94,7 +99,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Tool restr #### What the model sees -The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent. +The model sees the generated [`read`, `read_image`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. `read_image` appears only while a durable attachment store is mounted; the schema itself is route-independent, and the strict gate refuses at execution. Scoped tool restrictions can remove any definition for one agent. #### Token effect @@ -118,6 +123,20 @@ Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; t Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### Image read result + +#### What the model sees + +A successful `read_image` returns ``, `image`, and a `` envelope naming the media type, dimensions, and byte size, followed by the image itself as a native image block. The session log stores only the durable `sha256:` attachment reference; the routed provider re-reads and digest-verifies the bytes on each request. + +#### Token effect + +The image is billed on every later request until compaction. Each call is independently bounded by the attachment store's `maxImageBytes`/`maxImagePixels`; repeated successful calls accumulate history, and content addressing deduplicates only the stored bytes, not the per-request token cost. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Write and edit results #### What the model sees @@ -136,7 +155,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual PNG/JPEG/WebP/GIF format`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. #### Token effect @@ -149,5 +168,8 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies ripgrep-backed `glob` and `grep` rather than extending the filesystem seam. -- **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`. +- **`read` handles UTF-8 text files only** — images use the separate extension-routed `read_image` tool; PDF, audio, and video remain deferred. A directory target is `FS_NOT_REGULAR_FILE`. +- **The route gate races a concurrent model switch** — `read_image` checks the latest routed model at execution; a switch committed between that check and the next request can leave an image block on a route that rejects image content. The Web host already refuses switching an image-bearing session to a text-only model; other front doors own their equivalent guard. +- **Extension-declared media type** — the extension selects the declared type and the attachment store's magic-byte validation stays authoritative; a correctly formatted image under a wrong extension is refused with the rename remedy rather than sniffed. +- **No inline image preview on the tool-result card** — UI surfaces render the image result generically (the durable reference, not pixels); inline rendering is deferred to the UI packages. - **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../README.md#no-timeouts-on-file-io)). diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 5bcf9c471d..be87bb18a1 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -2,17 +2,20 @@ [English](README.md) | 中文 -**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。 +**面向模型的文件系统工具**(`read`、`read_image`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。 ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) -await ctx.plugin(ToolFs) // this package — registers read/write/edit +await ctx.plugin(LocalAttachmentStore, { dshHome }) // optional — enables durable read_image results +await ctx.plugin(ToolFs) // this package — read/write/edit, plus read_image with attachments ``` `@deepseek-ai/dsh-fs-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供写入/编辑前读取行为。 +`read_image` 只在持久 `ctx.attachments` 服务已挂载时注册:没有它,部署无法持久提交图像字节,工具就不会出现。执行时还要求确切路由的模型声明 `image` 输入(通过 `ctx.llm.resolveModelInfo` 从会话最新请求 header 解析,缺失时回退到 agent 选项);未知或纯文本路由在任何文件系统 I/O 之前就得到拒绝结果,因此文本路由的持久历史不会出现图像块。 + ## 配置 所有键均为可选;默认值是随产品交付的读取上限。 @@ -29,18 +32,20 @@ await ctx.plugin(ToolFs) // this package — re | 工具 | 参数 | 行为 | |---|---|---| | `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容和分页 footer。`offset` 从 1 开始;`limit` 默认为配置的 `readLimit`(2000),上限也为该值。 | +| `read_image` | `file_path` | 通过有界字节 seam 读取 PNG/JPEG/WebP/GIF 文件,经 `ctx.attachments.saveImage` 持久保存,并在小型元数据信封旁返回图像块。只有确切路由的模型声明图像输入时才会成功。 | | `write` | `file_path`、`content` | 创建文件或完整替换文件。有策略插件时:覆盖现有文件要求先在未变版本上执行 `read`;创建新文件不需要。没有插件时:无条件执行。 | | `edit` | `file_path`、非空 `old_string`、`new_string`、`replace_all?` | 字面量替换;除非 `replace_all` 为 true,否则要求唯一匹配。有策略插件时:要求先执行 `read`(任何窗口),且文件此后未变。没有插件时:无条件执行。 | 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。 +规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name? } }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。 ## 工具就是执行器;策略是事件门禁 工具**不**注入策略服务,也不检查任何缓存。每个工具通过 `ctx.fs.resolve(path, { cwd, signal })` 解析路径;它会传入调用 agent(智能体)的会话 cwd(`exec.agent.session.header.cwd`),使相对路径以会话工作区为基准解析并与 `dsh-tool-bash` 一致,同时把工具取消转发到解析过程(见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md))。随后执行: - **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。(1 次 stat。) +- **read_image**:在任何 I/O 之前校验参数、扩展名、附件可用性、部署接受的媒体类型和图像路由;随后一次 `ctx.fs.stat`、以 `imageLimits.maxImageBytes` 为上限的有界 `ctx.fs.readBytes`、`attachments.saveImage`(内容寻址,因此在 `tool/result` 事件追加时图像块引用的对象已持久提交),最后发出 `fs/observed`。(1 次 stat。) - **write**:调用 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.writeText(target, content, intent)`,再发出 `fs/observed`。(0 次 stat。) - **edit**:调用 `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.editText(target, edit, intent)`,再发出 `fs/observed`。(0 次 stat。) @@ -50,11 +55,11 @@ await ctx.plugin(ToolFs) // this package — re ## `fs/observed` 发后即忘 -`fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的约定是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。 +`fs/observed` 在 read/read_image/write/edit 已经成功之后,通过普通 `ctx.emit` 发出。监听器的约定是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。 `read` 允许并发调度,因为其唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 -包根目录只导出 Cordis 插件约定(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis,单独进行单元测试);`src/read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 +包根目录只导出 Cordis 插件约定(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis,单独进行单元测试);`src/read.ts`/`read-image.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 ## 模型体验 @@ -94,7 +99,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -模型会看到已生成的 [`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。 +模型会看到已生成的 [`read`、`read_image`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。`read_image` 只在持久附件存储已挂载时出现;schema 本身与路由无关,严格门禁在执行时拒绝。作用域工具限制可以为某个 agent 移除任一定义。 #### Token 影响 @@ -118,6 +123,20 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces 仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 +### 图像读取结果 + +#### 模型看到什么 + +成功的 `read_image` 返回 ``、`image` 和写明媒体类型、尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。会话日志只存储持久的 `sha256:` 附件引用;路由到的提供方在每次请求时重新读取并校验字节摘要。 + +#### Token 影响 + +图像在之后每次请求中都会计费,直到压缩。每次调用都独立受附件存储的 `maxImageBytes`/`maxImagePixels` 约束;重复成功调用会在历史中累积,内容寻址只去重存储的字节,不去重每次请求的 token 成本。 + +#### KV 缓存影响 + +只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV 缓存条目失效。 + ### 写入与编辑结果 #### 模型看到的内容 @@ -136,7 +155,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual PNG/JPEG/WebP/GIF format`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。 #### Token 影响 @@ -149,5 +168,8 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces ## 已知限制与暂缓事项 - **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 ripgrep 的 `glob` 与 `grep`,而不是扩展文件系统 seam。 -- **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。 +- **`read` 只处理 UTF-8 文本文件**:图像使用独立的、按扩展名路由的 `read_image` 工具;PDF、音频和视频仍延期处理。目录目标为 `FS_NOT_REGULAR_FILE`。 +- **路由门禁与并发模型切换存在竞态**:`read_image` 在执行时检查最新路由的模型;在该检查与下一次请求之间提交的切换,可能让图像块落在拒绝图像内容的路由上。Web 宿主已拒绝把含图像的会话切到纯文本模型;其他前端拥有各自的等价防护。 +- **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。 +- **工具结果卡片没有内嵌图像预览**:UI 表面以通用形式渲染图像结果(持久引用而非像素);内嵌渲染延后到 UI 包处理。 - **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.md#no-timeouts-on-file-io))。 diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 8af8bc77ba..ceaf64b7ed 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -29,6 +29,7 @@ "schemastery": "^3.18.0" }, "peerDependencies": { + "@deepseek-ai/dsh-attachment": "^0.0.1", "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -44,6 +45,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index a4c96d606b..7d4d343972 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-user-approval' import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' +import { applyReadImageTool } from './read-image.ts' import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' import { FsSandboxSurface } from './sandbox.ts' @@ -63,6 +64,12 @@ export function apply(ctx: Context, config: Config): void { maxBytes: resolved.readMaxBytes, streamMinSize: resolved.readStreamMinSize, }) + // read_image is composition-conditional: without a mounted attachment store + // the deployment cannot durably commit image bytes, so the tool never + // registers; the execute body keeps a defensive re-check for direct callers. + ctx.inject(['attachments'], (imageCtx) => { + applyReadImageTool(imageCtx) + }) // One escalation surface shared by both mutating tools: advertisement gating, // per-call policy resolution, and denial-marker mapping, all keyed off whether // the mounted ctx.fs confines (ctx.fs.sandboxMode). diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts new file mode 100644 index 0000000000..b12bf52e99 --- /dev/null +++ b/packages/fs/tool-fs/src/read-image.ts @@ -0,0 +1,231 @@ +/** + * The model-facing `read_image` tool: reads a PNG/JPEG/WebP/GIF file, durably + * commits its bytes through the attachment service (the same lifecycle as a + * user-uploaded image), and returns an image block so the image enters model + * context from the next request onward. + * + * The route gate is deliberately stricter than the host upload preflight: a + * tool result enters durable session history, so emitting an image on a route + * that cannot carry it would break that route's continuation. Unknown + * capability therefore refuses instead of relying on the adapter guard. + * @module @deepseek-ai/dsh-tool-fs/src/read-image + */ + +import { basename, extname } from 'node:path' +import type { Context } from 'cordis' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' +import { FsError } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' +import { sessionResolveOptions } from './session-cwd.ts' + +/** Extensions `read_image` accepts; magic-byte validation at the attachment service stays authoritative. */ +const IMAGE_EXTENSIONS: Readonly> = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', +} + +/** The canonical outcome declared by the `read_image` output schema. */ +export interface ImageReadValue { + path: string + image: { + attachmentId: string + mediaType: ImageMediaType + bytes: number + width: number + height: number + name?: string + } +} + +/** + * Map a model-supplied path to its declared image media type by extension. + * @param filePath - the raw `file_path` argument (not yet resolved). + * @returns the declared media type, or undefined when the path does not claim an image. + */ +export function imageMediaTypeForPath(filePath: string): ImageMediaType | undefined { + return IMAGE_EXTENSIONS[extname(filePath).toLowerCase()] +} + +/** + * Enforce the strict image-capability gate for the calling route. Resolves the + * session's latest routed provider/model (request header config, then agent + * options) and requires the exact resolved route to declare `image` input explicitly. + * @param ctx - the plugin context used to resolve the optional `llm` service. + * @param exec - the tool-execution context supplying the calling agent. + * @param displayPath - the path rendered in refusal messages. + */ +export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, displayPath: string): Promise { + const routed = exec.agent?.session.requestHeader()?.config + const provider = routed?.provider ?? exec.agent?.options.provider + const model = routed?.model ?? exec.agent?.options.model + const llm = ctx.get('llm') + if (provider === undefined || model === undefined || llm === undefined) { + throw new Error(`cannot read "${displayPath}" as an image: the current model route could not be resolved`) + } + const active = await llm.resolveModelInfo(provider, model, exec.signal) + if (active.inputModalities === undefined || !active.inputModalities.includes('image')) { + throw new Error(`cannot read "${displayPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`) + } +} + +/** + * Re-brand a canonical image outcome into the durable attachment reference an + * `ImageBlock` carries. + * @param image - the canonical image metadata from the output schema. + * @returns the branded attachment reference. + */ +export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachmentRef { + return { + attachmentId: AttachmentId(image.attachmentId), + mediaType: image.mediaType, + bytes: image.bytes, + width: image.width, + height: image.height, + ...image.name === undefined ? {} : { name: image.name }, + } +} + +/** + * Format an image read as the model-facing envelope beside its image block. + * @param displayPath - the backend-resolved path rendered in the envelope's `` element. + * @param image - the canonical image metadata to summarize. + * @returns the model-facing envelope; the image itself rides the adjacent image block. + */ +export function formatImageReadOutput(displayPath: string, image: ImageReadValue['image']): string { + return `${displayPath} +image + +${image.mediaType} image, ${image.width}x${image.height} px, ${image.bytes} bytes +` +} + +/** + * Project one canonical image read into its model-facing envelope and image. + * @param value - the canonical image-read outcome. + * @returns the two content blocks used by native and nested dispatches. + */ +function imageReadContent(value: ImageReadValue): ContentBlock[] { + return [ + { type: 'text', text: formatImageReadOutput(value.path, value.image) }, + { type: 'image', attachment: imageRefFromValue(value.image) }, + ] +} + +/** + * Register the `read_image` tool. Execution gates on the optional + * `attachments`/`llm` services and the calling route's declared image input; + * registration itself is unconditional so denial happens at the operation + * boundary rather than through schema omission. + * @param ctx - the plugin context; registrations are effects scoped to it, and + * execution uses its `fs` service plus the optional `attachments`/`llm` services. + */ +export function applyReadImageTool(ctx: Context): void { + ctx.tools.register(defineTool({ + name: 'read_image', + description: 'Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.', + parameters: { + file_path: { type: 'string', required: true, description: 'Path to the image file, resolved by the filesystem backend.' }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + image: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + attachmentId: { type: 'string', required: true }, + mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], required: true }, + bytes: { type: 'integer', required: true }, + width: { type: 'integer', required: true }, + height: { type: 'integer', required: true }, + name: { type: 'string' }, + }, + }, + }, + }, + render: (_args, value) => imageReadContent(value), + }, + // Content-addressed attachment writes are idempotent, so concurrent reads + // of the same file cannot conflict. + isConcurrencySafe: () => true, + async execute(args, exec) { + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + + // Every gate runs before any filesystem I/O so a refusal never leaks + // partial reads or attachment writes. + const mediaType = imageMediaTypeForPath(args.file_path) + if (mediaType === undefined) { + throw new Error(`cannot read "${args.file_path}": read_image only accepts PNG/JPEG/WebP/GIF paths`) + } + const attachments = ctx.get('attachments') + if (attachments === undefined) { + throw new Error(`cannot read "${args.file_path}" as an image: no attachment service is mounted`) + } + if (!attachments.imageLimits.mediaTypes.includes(mediaType)) { + throw new Error(`cannot read "${args.file_path}": ${mediaType} images are not accepted by this deployment`) + } + await assertImageCapableRoute(ctx, exec, args.file_path) + + const target = await ctx.fs.resolve(args.file_path, sessionResolveOptions(exec, args.file_path)) + const info = await ctx.fs.stat(target, exec.signal) + if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + + const data = await ctx.fs.readBytes(target, exec.signal, attachments.imageLimits.maxImageBytes) + // Persist before returning: the image block must reference a durably + // committed object by the time the tool/result event is appended. + let ref: ImageAttachmentRef + try { + ref = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) }) + } catch (error: unknown) { + if (!(error instanceof AttachmentError) || error.code !== 'IMAGE_TYPE_MISMATCH') throw error + const extension = extname(target.displayPath).toLowerCase() + throw new Error( + `cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual PNG/JPEG/WebP/GIF format`, + { cause: error }, + ) + } + ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec) + const value: ImageReadValue = { + path: target.displayPath, + image: { + attachmentId: ref.attachmentId, + mediaType: ref.mediaType, + bytes: ref.bytes, + width: ref.width, + height: ref.height, + ...ref.name === undefined ? {} : { name: ref.name }, + }, + } + if (exec.parent !== undefined) { + exec.deferContext(createUserMessage({ + content: imageReadContent(value), + source: { kind: 'plugin', plugin: 'tool-fs' }, + })) + } + return value + }, + // Pure display: a generic card in the read family with a follow-along + // location on the image file. + presentCall(args): GenericCallView { + return { + card: 'generic', + title: `Read image ${args.file_path}`, + kind: 'read', + locations: [{ path: args.file_path }], + } + }, + })) +} diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts new file mode 100644 index 0000000000..229069a8d2 --- /dev/null +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -0,0 +1,462 @@ +/** + * The `read_image` tool over the REAL local filesystem and attachment store: + * extension routing, the strict image-modality gate (every refusal arm), + * durable commit + image-block rendering, attachment admission failures, and + * the regression that `read` keeps its text-only contract. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import type { Config as ToolConfig } from '@deepseek-ai/dsh-tools' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' +import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import { + applyReadImageTool, + formatImageReadOutput, + imageMediaTypeForPath, + imageRefFromValue, +} from '../src/read-image.ts' + +/** 1x1 red PNG (valid signature, IHDR, IDAT). */ +const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64') +/** 3x3 red PNG used to trip a tiny configured pixel limit. */ +const PNG_3X3 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAEElEQVR4nGP4z8AAQQxYWACPjgj4kWPEuQAAAABJRU5ErkJggg==', 'base64') + +const testToolSignal = new AbortController().signal + +/** Exact-route fake adapter; `stream` is unreachable in these tests. */ +class CatalogAdapter extends LlmAdapter { + constructor( + private readonly models: LlmModelInfo[], + private readonly resolvedModels: LlmModelInfo[] = models, + ) { + super() + } + + override listModels(_provider: string): Promise { + return Promise.resolve(this.models) + } + + override resolveModel(provider: string, model: string): Promise { + const resolved = this.resolvedModels.find(candidate => candidate.id === model) + return Promise.resolve({ + provider, + id: model, + name: resolved?.name ?? model, + ...resolved?.inputModalities === undefined ? {} : { inputModalities: [...resolved.inputModalities] }, + }) + } + + override stream(_options: GenerateOptions): AsyncIterable { + throw new Error('read_image tests never stream') + } +} + +/** In-process Code Mode seam fake that invokes the real registry bindings. */ +class FakeRuntime extends CodeRuntime { + readonly language = 'typescript' + readonly isolation = 'fake' + behavior: (request: CodeRunRequest) => Promise = () => Promise.resolve({ logs: [] }) + + run(request: CodeRunRequest): Promise { + return this.behavior(request) + } +} + +let dir: string +let home: string + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-read-image-')) + home = await mkdtemp(join(tmpdir(), 'dsh-read-image-home-')) +}) +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) +}) + +interface SetupOptions { + models?: LlmModelInfo[] + resolvedModels?: LlmModelInfo[] + attachments?: boolean + llm?: boolean + storeConfig?: { maxImageBytes?: number; maxImagePixels?: number } + toolMode?: ToolConfig['mode'] +} + +async function setup(options: SetupOptions = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: options.toolMode ?? 'native' }) + if (options.toolMode === 'code' || options.toolMode === 'both') { + await ctx.plugin(FakeRuntime) + } + await ctx.plugin(LocalFileSystem, { cwd: dir }) + await ctx.plugin(FsPolicy) + if (options.attachments !== false) { + await ctx.plugin(LocalAttachmentStore, { dshHome: home, ...options.storeConfig }) + } + if (options.llm !== false) { + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['visual'], new CatalogAdapter(options.models ?? [ + { provider: 'visual', id: 'vision-model', name: 'Vision', inputModalities: ['text', 'image'] }, + { provider: 'visual', id: 'text-model', name: 'Text', inputModalities: ['text'] }, + { provider: 'visual', id: 'legacy-model', name: 'Legacy' }, + ], options.resolvedModels)) + } + await ctx.plugin(ToolFs) + return ctx +} + +/** A fake calling agent pinned to one routed provider/model. */ +function agentOn(model: string | undefined, provider = 'visual'): object { + return { + options: {}, + session: { + header: { cwd: dir }, + requestHeader: () => (model === undefined ? undefined : { config: { provider, model } }), + append: () => undefined, + }, + } +} + +let callCounter = 0 +function call(ctx: Context, name: string, args: unknown, agent?: object) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`img-call-${++callCounter}`), + name, + arguments: args, + ...agent ? { agent: agent as never } : {}, + }) +} + +function readImage(ctx: Context, args: unknown, agent?: object) { + return call(ctx, 'read_image', args, agent) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('imageMediaTypeForPath', () => { + it('maps the four extensions case-insensitively and rejects everything else', () => { + expect(imageMediaTypeForPath('a.png')).toBe('image/png') + expect(imageMediaTypeForPath('a.JPG')).toBe('image/jpeg') + expect(imageMediaTypeForPath('b.jpeg')).toBe('image/jpeg') + expect(imageMediaTypeForPath('c.webp')).toBe('image/webp') + expect(imageMediaTypeForPath('d.Gif')).toBe('image/gif') + expect(imageMediaTypeForPath('note.txt')).toBeUndefined() + expect(imageMediaTypeForPath('png')).toBeUndefined() + }) +}) + +describe('imageRefFromValue', () => { + it('re-brands with and without the optional display name', () => { + const base = { attachmentId: 'sha256:00', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 } + expect(imageRefFromValue(base)).toEqual(base) + expect(imageRefFromValue({ ...base, name: 'a.png' })).toEqual({ ...base, name: 'a.png' }) + }) +}) + +describe('read_image happy path', () => { + it('commits the bytes durably and renders the envelope beside an image block', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + + expect(result.isError).toBe(false) + expect(result.content).toHaveLength(2) + const image = result.content[1] as { type: string; attachment: ImageAttachmentRef } + expect(image.type).toBe('image') + expect(image.attachment.mediaType).toBe('image/png') + expect(image.attachment.width).toBe(1) + expect(image.attachment.height).toBe(1) + expect(image.attachment.bytes).toBe(PNG_1X1.length) + expect(image.attachment.name).toBe('red.png') + expect(image.attachment.attachmentId).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(text(result)).toBe(formatImageReadOutput(join(dir, 'red.png'), { + attachmentId: image.attachment.attachmentId, + mediaType: 'image/png', + bytes: PNG_1X1.length, + width: 1, + height: 1, + })) + + // The committed object must read back verbatim through the store. + const attachments = ctx.get('attachments') + if (attachments === undefined) throw new Error('expected the attachment service') + const stored = await attachments.readImage(image.attachment) + expect(Buffer.from(stored.data)).toEqual(PNG_1X1) + }) + + it('emits fs/observed for the read image', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const observed: string[] = [] + ctx.on('fs/observed', target => void observed.push(target.displayPath)) + await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(observed).toEqual([join(dir, 'red.png')]) + }) + + it('falls back to agent options when no request header exists yet', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const agent = { + options: { provider: 'visual', model: 'vision-model' }, + session: { header: { cwd: dir }, requestHeader: () => undefined }, + } + const result = await readImage(ctx, { file_path: 'red.png' }, agent) + expect(result.isError).toBe(false) + }) + + it('forwards a nested Code Mode image through the outer run_code context', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ toolMode: 'code' }) + const runtime = ctx.codeRuntime as FakeRuntime + runtime.behavior = async (request) => { + const value = await request.bindings[0]!.functions.read_image!({ file_path: 'red.png' }) + return { logs: [], value } + } + + const result = await call(ctx, RUN_CODE_NAME, { + code: 'return await tools.read_image({ file_path: "red.png" })', + description: 'Read the image through Code Mode', + }, agentOn('vision-model')) + + expect(result.isError).toBe(false) + expect(result.content.every(block => block.type === 'text')).toBe(true) + expect(result.additionalContexts).toHaveLength(1) + const forwarded = result.additionalContexts?.[0]?.content + expect(forwarded).toHaveLength(2) + expect(forwarded?.[0]?.type).toBe('text') + expect(forwarded?.[0]?.type === 'text' ? forwarded[0].text : '').toContain('image') + expect(forwarded?.[1]).toMatchObject({ + type: 'image', + attachment: { mediaType: 'image/png', width: 1, height: 1 }, + }) + }) +}) + +describe('strict image-modality gate', () => { + it('accepts an exact visual route even when the advisory model catalog omits it', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ + models: [], + resolvedModels: [ + { provider: 'visual', id: 'hidden-vision', name: 'Hidden Vision', inputModalities: ['text', 'image'] }, + ], + }) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('hidden-vision')) + expect(result.isError).toBe(false) + }) + + it.each([ + ['a text-only model', 'text-model'], + ['a model without declared modalities', 'legacy-model'], + ['a model absent from the catalog', 'unknown-model'], + ])('refuses on %s', async (_label, model) => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn(model)) + expect(result.isError).toBe(true) + expect(text(result)).toContain('does not declare image input') + }) + + it('refuses when the route cannot be resolved (no agent, or no header and no options)', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const noAgent = await readImage(ctx, { file_path: 'red.png' }) + expect(noAgent.isError).toBe(true) + expect(text(noAgent)).toContain('route could not be resolved') + + const noRoute = await readImage(ctx, { file_path: 'red.png' }, agentOn(undefined)) + expect(noRoute.isError).toBe(true) + expect(text(noRoute)).toContain('route could not be resolved') + }) + + it('refuses when no llm service is mounted', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ llm: false }) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('route could not be resolved') + }) +}) + +describe('argument and service preconditions', () => { + it('rejects an empty path and a non-image extension', async () => { + const ctx = await setup() + const empty = await readImage(ctx, { file_path: ' ' }, agentOn('vision-model')) + expect(empty.isError).toBe(true) + expect(text(empty)).toContain('non-empty') + + const nonImage = await readImage(ctx, { file_path: 'notes.txt' }, agentOn('vision-model')) + expect(nonImage.isError).toBe(true) + expect(text(nonImage)).toContain('only accepts PNG/JPEG/WebP/GIF paths') + }) + + it('refuses when no attachment service is mounted', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ attachments: false }) + expect(ctx.tools.get('read_image')).toBeUndefined() + expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('read_image') + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('unknown tool "read_image"') + }) + + it('defensively refuses execution without an attachment service', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ attachments: false }) + applyReadImageTool(ctx) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no attachment service is mounted') + }) + + it('refuses a media type the deployment does not accept', async () => { + /** Store whose deployment accepts JPEG only. */ + class JpegOnlyStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + mediaTypes: Object.freeze(['image/jpeg'] as const), + }) + + validateImage(_input: SaveImageAttachment): Promise { + throw new Error('unreachable: admission refuses before validation') + } + + saveImage(_input: SaveImageAttachment): Promise { + throw new Error('unreachable: admission refuses before save') + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + const ctx = await setup({ attachments: false }) + await ctx.plugin(JpegOnlyStore) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('image/png images are not accepted by this deployment') + }) +}) + +describe('image admission failures', () => { + it('explains how to repair a declared/actual media-type mismatch', async () => { + await writeFile(join(dir, 'wrong.jpg'), PNG_1X1) + const ctx = await setup() + const result = await readImage(ctx, { file_path: 'wrong.jpg' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('the .jpg extension declares image/jpeg') + expect(text(result)).toContain('rename the file to match its actual PNG/JPEG/WebP/GIF format') + }) + + it('fails with FS_TOO_LARGE before reading a file past maxImageBytes', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ storeConfig: { maxImageBytes: PNG_1X1.length - 1 } }) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('exceeds') + }) + + it('surfaces the pixel limit from the attachment admission', async () => { + await writeFile(join(dir, 'big.png'), PNG_3X3) + const ctx = await setup({ storeConfig: { maxImagePixels: 4 } }) + const result = await readImage(ctx, { file_path: 'big.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + }) + + it('reports a missing image file and a directory target through the fs vocabulary', async () => { + await mkdir(join(dir, 'folder.png')) + const ctx = await setup() + const missing = await readImage(ctx, { file_path: 'absent.png' }, agentOn('vision-model')) + expect(missing.isError).toBe(true) + expect(text(missing)).toContain('not found') + + const directory = await readImage(ctx, { file_path: 'folder.png' }, agentOn('vision-model')) + expect(directory.isError).toBe(true) + expect(text(directory)).toContain('not a regular file') + }) + + it('omits the display name when the store returns a reference without one', async () => { + /** Store echoing a fixed nameless reference; deployments may strip names entirely. */ + class NamelessStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + mediaTypes: Object.freeze(['image/png'] as const), + }) + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + async saveImage(input: SaveImageAttachment): Promise { + return { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 } + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ attachments: false }) + await ctx.plugin(NamelessStore) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(false) + const image = result.content[1] as { attachment: ImageAttachmentRef } + expect(image.attachment.name).toBeUndefined() + }) +}) + +describe('registration surface', () => { + it('declares read_image parallel-safe and presents a read-family card', async () => { + const ctx = await setup() + expect(ctx.tools.executionMode({ + signal: testToolSignal, callId: CallId('img-parallel'), name: 'read_image', arguments: { file_path: 'a.png' }, + })).toEqual({ kind: 'parallel' }) + expect(ctx.tools.get('read_image')?.presentCall?.({ file_path: 'shot.png' })).toEqual({ + card: 'generic', + title: 'Read image shot.png', + kind: 'read', + locations: [{ path: 'shot.png' }], + }) + }) +}) + +describe('read keeps its text-only contract', () => { + it('still refuses a PNG as a binary file and line-numbers text', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + await writeFile(join(dir, 'note.txt'), 'hello\nworld') + const ctx = await setup() + + const png = await call(ctx, 'read', { file_path: 'red.png' }, agentOn('vision-model')) + expect(png.isError).toBe(true) + expect(text(png)).toContain('binary file') + + const txt = await call(ctx, 'read', { file_path: 'note.txt' }, agentOn('text-model')) + expect(txt.isError).toBe(false) + expect(text(txt)).toContain('1: hello') + expect(text(txt)).toContain('file') + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index b217260972..78289d6c31 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -71,6 +71,13 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } + override async readBytes(target: FsTarget, _signal: AbortSignal | undefined, maxBytes: number): Promise { + const bytes = new TextEncoder().encode(this.files.get(target.targetKey) ?? '') + if (bytes.length > maxBytes) { + throw new FsError(`too large: ${target.displayPath}`, 'FS_TOO_LARGE') + } + return bytes + } override async listDir(_target: FsTarget): Promise { return [] } diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index a3f128143c..62e7230554 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -41,6 +41,9 @@ }, { "path": "../../interaction/user-approval" + }, + { + "path": "../../attachment/attachment" } ] } diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 30d3f6a602..a564507447 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -424,6 +424,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise>', jsDoc: '/**\n * Stream the whole regular text file as decoded text chunks (same text\n * semantics as {@link readText}, for large files). The backend owns\n * cross-chunk UTF-8 decoding and binary rejection so the policy layer never\n * touches raw bytes.\n * @param target - the resolved target to read.\n * @param signal - aborts the stream, including between chunks.\n * @returns the chunk iterable, decoded and validated like {@link readText}.\n */', }, + { + signature: 'abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise', + jsDoc: '/**\n * Read the whole regular file as raw bytes with no decoding or binary\n * rejection. The bound lives at this seam so a backend can never buffer an\n * unbounded file: a target known or discovered to exceed `maxBytes` fails\n * with `FS_TOO_LARGE` instead of returning a truncated result.\n * @param target - the resolved target to read.\n * @param signal - aborts the read.\n * @param maxBytes - inclusive byte cap on the complete content.\n * @returns the full raw content, at most `maxBytes` long.\n */', + }, { signature: 'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise', jsDoc: '/**\n * List direct children of a directory in stable name order. Returns resolved\n * child targets plus cheap metadata only; never reads file contents.\n * @param target - the resolved directory target.\n * @param signal - aborts the listing.\n * @returns one entry per direct child, in stable name order.\n */', diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 8bb08d2947..ca547aaab8 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -96,6 +96,10 @@ class TestFileSystem extends FileSystem { throw new Error('not needed in skill tests') } + override async readBytes(_target: FsTarget, _signal: AbortSignal | undefined, _maxBytes: number): Promise { + throw new Error('not needed in skill tests') + } + override async listDir(target: FsTarget): Promise { this.listDirCalls += 1 if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed') diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index 6d048c2325..4cef8ce1c7 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/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/support/llm-replay/README.md -README.md: ae7cb5414a28bcf33f9878f6f02220861df9a0d5 -README.zh.md: 2acc0ff8e8c011126452ba3458aaeb0800d9d8f3 +README.md: ade19b66f96ef26119944cbebba23c43e4b2117c +README.zh.md: 4752e03b04be175d7f3e37fc57fe9c74e1f8a303 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index ae7cb5414a..ade19b66f9 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -29,7 +29,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s | `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). | | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional `ReplayOverrideDoc` sidecar for the primary session: a bare `ReplayEntry[]` replaces its derived script, while `{ patches }` augments it by call index. | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. | -| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. | +| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow` and declared `inputModalities` (so a scenario can exercise capability gates such as the image-capable `read_image` route check); configured routes dispatch through the replay adapter and never perform provider I/O. | | `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. | ```yaml diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index 2acc0ff8e8..4752e03b04 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -29,7 +29,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as | `file` | string | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径。必需(配置或 env)。 | | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` 伴随文件:裸 `ReplayEntry[]` 替换其派生脚本,`{ patches }` 则按调用索引增补该脚本。 | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES`(以路径分隔符分隔) | 嵌套场景中已记录的 subagent 子会话日志;单会话场景为空。 | -| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow`;已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | +| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow` 和声明的 `inputModalities`(让场景能够触发能力门禁,例如 `read_image` 的图像路由检查);已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | | `paceMs` | number | 无(突发) | 可选的每分片毫秒延迟,使下游传输(例如真实浏览器观察到的 Web SSE(Server-Sent Events)多路复用器)看到真正的增量传递。它只是仿真开关,测试不得依赖它保证正确性。值必须是非负整数;pace 等待期间中止会迅速取消流。 | ```yaml diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 9af72bbbec..61b1142383 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -19,6 +19,7 @@ import type { LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, + ModelModality, ResolvedRetryPolicy, RetryPolicyConfig, StreamChunk, @@ -51,6 +52,8 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** Optional declared input modalities, so a scenario can exercise capability gates (e.g. image-capable `read_image`). */ + inputModalities?: readonly ModelModality[] } /** One provider route exposed by the replay adapter. */ @@ -569,6 +572,7 @@ class ReplayAdapter extends LlmAdapter { id: model.id, name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, + ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] }, }))) } @@ -582,6 +586,9 @@ class ReplayAdapter extends LlmAdapter { id: model, name: configuredModel?.name ?? model, ...configuredModel?.description === undefined ? {} : { description: configuredModel.description }, + ...configuredModel?.inputModalities === undefined + ? {} + : { inputModalities: [...configuredModel.inputModalities] }, ...configuredModel?.contextWindow === undefined ? {} : { context: { contextWindow: configuredModel.contextWindow } }, diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index f9aa66bce5..0c36dca1ff 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -592,7 +592,7 @@ describe('installLlmReplay (through the real LlmService)', () => { backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, }, models: [ - { id: 'flash', contextWindow: 128_000 }, + { id: 'flash', contextWindow: 128_000, inputModalities: ['text', 'image'] }, { id: 'pro', name: 'Pro', description: 'Larger model' }, ], }, @@ -605,13 +605,15 @@ describe('installLlmReplay (through the real LlmService)', () => { { id: 'empty', name: 'empty' }, ]) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'flash', name: 'flash' }, + { provider: 'deepseek', id: 'flash', name: 'flash', inputModalities: ['text', 'image'] }, { provider: 'deepseek', id: 'pro', name: 'Pro', description: 'Larger model' }, ]) await expect(ctx.llm.listModels('empty')).resolves.toEqual([]) await expect(ctx.llm.resolveModelInfo('deepseek', 'flash')).resolves.toMatchObject({ context: { contextWindow: 128_000 }, + inputModalities: ['text', 'image'], }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('inputModalities') await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context') await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted')).resolves.not.toHaveProperty('context') await expect(ctx.llm.resolveModelInfo('empty', 'unlisted')).resolves.not.toHaveProperty('context') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9561006aa..b8d6544783 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -397,6 +397,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../packages/boot/app-boot + '@deepseek-ai/dsh-attachment-local': + specifier: workspace:* + version: link:../packages/attachment/attachment-local '@deepseek-ai/dsh-bash': specifier: workspace:* version: link:../packages/bash/bash @@ -3730,6 +3733,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b95adbdec5..40fd8d7d4b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -460,6 +460,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Promise', 'Record', 'Readonly', + 'Uint8Array', ]) /** Project types deliberately documented outside the subsystems catalog. */ diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 942a9ec9e0..ed74dbf802 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -24,6 +24,8 @@ import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import PlanModeService from '@deepseek-ai/dsh-plan-mode' import WebService from '@deepseek-ai/dsh-web' @@ -60,6 +62,29 @@ import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' +/** Attachment seam marker that makes the attachments-conditional `read_image` schema harvestable. */ +class CatalogAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, + maxImagePixels: 1, + mediaTypes: Object.freeze(['image/png'] as const), + }) + + override validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('gen-tool-catalog: attachment validation is unreachable during schema harvest')) + } + + override saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('gen-tool-catalog: attachment writes are unreachable during schema harvest')) + } + + override readImage(_ref: ImageAttachmentRef): Promise { + return Promise.reject(new Error('gen-tool-catalog: attachment reads are unreachable during schema harvest')) + } +} + const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' @@ -265,16 +290,18 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-fs', dir: 'tool-fs', source: 'packages/fs/tool-fs/src/index.ts', - requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'], - writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful mutation', 'tool/result'], + requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt', 'ctx.attachments (read_image registration)', 'ctx.llm + an image-capable route (read_image execution)'], + writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful file operation', 'durable attachment (read_image)', 'tool/result'], async mount(ctx) { // The tool needs `fs`; the bare provider is sufficient because policy - // changes behavior, not schema shape. + // changes behavior, not schema shape. The catalog seam marker opts into + // the attachments-conditional read_image schema without attachment I/O. await ctx.plugin(LocalFileSystem) + await ctx.plugin(CatalogAttachmentStore) await ctx.plugin(ToolFs) }, note: - 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.', + 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input.', }, { pkg: '@deepseek-ai/dsh-tool-fs-search', From 97a9ec5a0e64401a966fac9ecb1515bb30b84a2d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 15:35:33 +0800 Subject: [PATCH 08/77] fix(review): bound E2B readBytes at the seam, prove conditional-registration disposal, and align read_image contracts - E2BFileSystem.readBytes now short-circuits on the stat size before any content transfer and streams the remote object, cancelling at the first chunk past the cap, honoring the seam's bounded-buffering contract; the fs-e2b README pair documents the new primitive. - The tool-fs HMR test now proves the attachments-scoped registration: disposing the store withdraws read_image while read/write/edit stay, remounting restores it, and disposing the plugin withdraws everything. - read_image caps reads at the smaller of maxImageBytes and maxMessageImageBytes, records an absent observation for a missing target like its sibling read, widens the mismatch remedy to cover out-of-family formats, and renames the gate's parameter to requestedPath; module/apply/registration JSDoc now match the shipped composition. zh terminology aligned; the examples manifest keeps its literal arrow. --- examples/package.json | 2 +- packages/e2b/fs-e2b/README.i18n.yaml | 4 +- packages/e2b/fs-e2b/README.md | 1 + packages/e2b/fs-e2b/README.zh.md | 1 + packages/e2b/fs-e2b/src/index.ts | 60 ++++++++++++++++---- packages/e2b/fs-e2b/tests/filesystem.spec.ts | 16 ++++++ packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 4 +- packages/fs/tool-fs/README.zh.md | 10 ++-- packages/fs/tool-fs/src/index.ts | 4 +- packages/fs/tool-fs/src/read-image.ts | 33 ++++++----- packages/fs/tool-fs/tests/read-image.spec.ts | 41 ++++++++++++- 12 files changed, 141 insertions(+), 39 deletions(-) diff --git a/examples/package.json b/examples/package.json index dce26b87da..a7440e3616 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", diff --git a/packages/e2b/fs-e2b/README.i18n.yaml b/packages/e2b/fs-e2b/README.i18n.yaml index 64460f7e87..feb950b196 100644 --- a/packages/e2b/fs-e2b/README.i18n.yaml +++ b/packages/e2b/fs-e2b/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/e2b/fs-e2b/README.md -README.md: 1b66e84defb56cbfaa4a91d6ba6b48377fb52ca9 -README.zh.md: d9cd3ce1e109bf6b0b7fae02157d1ec6be51e575 +README.md: 9171989f968f144593107eb918fe75cd12de7768 +README.zh.md: 9f50bbe4c37bbbfb641690a690be45dbb5158258 diff --git a/packages/e2b/fs-e2b/README.md b/packages/e2b/fs-e2b/README.md index 1b66e84def..9171989f96 100644 --- a/packages/e2b/fs-e2b/README.md +++ b/packages/e2b/fs-e2b/README.md @@ -9,6 +9,7 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide - **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute. - **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules. - **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing. +- **Bounded raw-byte reads** — `readBytes` short-circuits on the stat size before any content transfer, then streams the remote object and cancels the stream at the first chunk past `maxBytes` (`FS_TOO_LARGE`), so neither an at-rest oversized file nor a post-stat grower is buffered whole in host memory. The empty-file quirk of the pinned SDK (content-length 0 returns `''` in stream format) yields an empty result. - **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln -T` instead, making the commit atomically no-replace even when a directory appears at the destination; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. - **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before publication. The signal is not forwarded into the rename or guarded-link commit, so cancellation cannot interrupt atomic publication or turn a committed write into a reported failure. diff --git a/packages/e2b/fs-e2b/README.zh.md b/packages/e2b/fs-e2b/README.zh.md index d9cd3ce1e1..9f50bbe4c3 100644 --- a/packages/e2b/fs-e2b/README.zh.md +++ b/packages/e2b/fs-e2b/README.zh.md @@ -9,6 +9,7 @@ - **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;GNU `realpath -mz` 提供规范化目标身份,且不要求最终文件存在;ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam;目录列表会复用已返回的元数据,并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。 - **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。 - **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。 +- **有界原始字节读取**:`readBytes` 在任何内容传输之前先按 stat 大小短路,然后流式读取远程对象,并在第一个超过 `maxBytes` 的分片处取消流(`FS_TOO_LARGE`),因此静态超限文件和 stat 后增长的文件都不会被完整缓冲进宿主内存。所钉版本 SDK 的空文件怪癖(content-length 为 0 时 stream 格式返回 `''`)产生空结果。 - **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,并保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln -T` 发布,即使目标位置出现目录,也能使提交具备原子且不替换的语义;系统会把提交前从暂存文件读取的元数据投影到目标路径,以生成返回的版本,因此任何一类提交点之后都不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。 - **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在发布前立即检查。信号不会传入 rename 或防护链接提交,因此取消无法中断原子发布,也不会把已提交的写入报告为失败。 diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index e8921909d4..75005fa3ad 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -229,20 +229,59 @@ export class E2BFileSystem extends FileSystem { override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise { const sandbox = await this.ctx.e2b.getSandbox() - await this.requireRegular(target, signal) - let bytes: Uint8Array + const info = await this.requireRegular(target, signal) + if (info.size !== undefined && info.size > maxBytes) { + throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + } + let stream: ReadableStream try { - // The E2B files API returns the whole object; the remote sandbox owns - // that buffering, so the seam bound is enforced on the complete result. - bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) }) + // Same pinned-SDK quirk as streamText: content-length 0 returns '' + // instead of a ReadableStream. + const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as + ReadableStream | string + stream = typeof read === 'string' + ? new ReadableStream({ start(controller) { controller.close() } }) + : read } catch (error: unknown) { throw mapError(error, 'read', target.displayPath, signal) } - assertNotAborted(signal, 'read') - if (bytes.byteLength > maxBytes) { - throw new FsError(`cannot read "${target.displayPath}": ${bytes.byteLength} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let bytes = 0 + let completed = false + try { + while (true) { + assertNotAborted(signal, 'read') + const next = await reader.read() + if (next.done) break + // The stat preflight covers the at-rest case; this streamed bound stops + // a post-stat grower without transferring past the first overflowing chunk. + bytes += next.value.byteLength + if (bytes > maxBytes) { + throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + } + chunks.push(next.value) + } + completed = true + } catch (error: unknown) { + throw mapError(error, 'read', target.displayPath, signal) + } finally { + if (!completed) { + try { + await reader.cancel() + } catch (_streamCancellationFailure) { + // The read already failed; a cancellation failure on the abandoned + // remote stream adds nothing actionable for the caller. + } + } } - return bytes + const whole = new Uint8Array(bytes) + let offset = 0 + for (const chunk of chunks) { + whole.set(chunk, offset) + offset += chunk.byteLength + } + return whole } override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { @@ -430,10 +469,11 @@ export class E2BFileSystem extends FileSystem { } } - private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise { + private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise { const info = await this.stat(target, signal) if (info === undefined) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + return info } private checkWriteIntent(existing: EntryInfo | undefined, expected: FsWriteIntent | undefined, target: FsTarget): void { diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 44049b7972..648db7b105 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -488,6 +488,22 @@ describe('E2BFileSystem identity, metadata, and reads', () => { await expectCode(fs.readBytes(target, undefined, 4), 'FS_ABORTED') }) + it('readBytes bounds a post-stat grower mid-stream and reads an empty file through the SDK quirk', async () => { + const remote = new FakeRemote() + remote.file('/workspace/grow.bin', [1, 1, 1, 1]) + remote.file('/workspace/empty.bin', '') + const { fs } = await setup(remote) + + remote.streamChunks = [bytes([1, 1, 1]), bytes([1, 2, 2])] + remote.streamKeepOpen = true + await expectCode(fs.readBytes(await fs.resolve('grow.bin'), undefined, 4), 'FS_TOO_LARGE') + expect(remote.streamCancel).toHaveBeenCalledOnce() + + remote.streamChunks = undefined + remote.streamKeepOpen = false + expect((await fs.readBytes(await fs.resolve('empty.bin'), undefined, 4)).byteLength).toBe(0) + }) + it('honors aborts before and during remote reads', async () => { const remote = new FakeRemote() remote.file('/workspace/a', 'a') diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index b83cc55426..193fd1a627 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: e295ad63902cbae245229fa86b780aeb59de808d -README.zh.md: be87bb18a1c49654d07977b5c8e2577188517077 +README.md: 7e334f886747cd8dc566a572c699cd80c7cf62fe +README.zh.md: b5eb5ae38aba049d77375d31d1509342a23f13fc diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index e295ad6390..7e334f8867 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -45,7 +45,7 @@ Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], t The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then: - **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.) -- **read_image** — validates the argument, extension, attachment availability, deployment media types, and the image-capable route before any I/O; then one `ctx.fs.stat`, a bounded `ctx.fs.readBytes` capped at `imageLimits.maxImageBytes`, `attachments.saveImage` (content-addressed, so the image block references a durably committed object by the time `tool/result` is appended), and finally `fs/observed`. (1 stat.) +- **read_image** — validates the argument, extension, attachment availability, deployment media types, and the image-capable route before any I/O; then one `ctx.fs.stat` (recording an `absent` observation for a missing target, like `read`), a bounded `ctx.fs.readBytes` capped at the smaller of `imageLimits.maxImageBytes` and `imageLimits.maxMessageImageBytes` (the result is one message carrying one image), `attachments.saveImage` (content-addressed, so the image block references a durably committed object by the time `tool/result` is appended), and finally `fs/observed`. (1 stat.) - **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.) - **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.) @@ -155,7 +155,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual PNG/JPEG/WebP/GIF format`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index be87bb18a1..b5eb5ae38a 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -45,7 +45,7 @@ await ctx.plugin(ToolFs) // this package — re 工具**不**注入策略服务,也不检查任何缓存。每个工具通过 `ctx.fs.resolve(path, { cwd, signal })` 解析路径;它会传入调用 agent(智能体)的会话 cwd(`exec.agent.session.header.cwd`),使相对路径以会话工作区为基准解析并与 `dsh-tool-bash` 一致,同时把工具取消转发到解析过程(见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md))。随后执行: - **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。(1 次 stat。) -- **read_image**:在任何 I/O 之前校验参数、扩展名、附件可用性、部署接受的媒体类型和图像路由;随后一次 `ctx.fs.stat`、以 `imageLimits.maxImageBytes` 为上限的有界 `ctx.fs.readBytes`、`attachments.saveImage`(内容寻址,因此在 `tool/result` 事件追加时图像块引用的对象已持久提交),最后发出 `fs/observed`。(1 次 stat。) +- **read_image**:在任何 I/O 之前校验参数、扩展名、附件可用性、部署接受的媒体类型和图像路由;随后一次 `ctx.fs.stat`(目标缺失时与 `read` 一样记录 `absent` 观察)、以 `imageLimits.maxImageBytes` 与 `imageLimits.maxMessageImageBytes` 中较小者为上限的有界 `ctx.fs.readBytes`(结果是携带一张图像的一条消息)、`attachments.saveImage`(内容寻址,因此在 `tool/result` 事件追加时图像块引用的对象已持久提交),最后发出 `fs/observed`。(1 次 stat。) - **write**:调用 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.writeText(target, content, intent)`,再发出 `fs/observed`。(0 次 stat。) - **edit**:调用 `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.editText(target, edit, intent)`,再发出 `fs/observed`。(0 次 stat。) @@ -125,7 +125,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces ### 图像读取结果 -#### 模型看到什么 +#### 模型看到的内容 成功的 `read_image` 返回 ``、`image` 和写明媒体类型、尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。会话日志只存储持久的 `sha256:` 附件引用;路由到的提供方在每次请求时重新读取并校验字节摘要。 @@ -133,9 +133,9 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces 图像在之后每次请求中都会计费,直到压缩。每次调用都独立受附件存储的 `maxImageBytes`/`maxImagePixels` 约束;重复成功调用会在历史中累积,内容寻址只去重存储的字节,不去重每次请求的 token 成本。 -#### KV 缓存影响 +#### KV Cache 影响 -只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV 缓存条目失效。 +仅追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV 缓存条目失效。 ### 写入与编辑结果 @@ -155,7 +155,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual PNG/JPEG/WebP/GIF format`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。 #### Token 影响 diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 7d4d343972..44ec754091 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,5 +1,5 @@ /** - * Model-facing read, write, and edit tools over `ctx.fs`. This package owns schemas, validation, + * Model-facing read, read_image, write, and edit tools over `ctx.fs`. This package owns schemas, validation, * read windows, formatting, and observation events, never a concrete provider. An optional * event policy supplies mutation guards; without one the tools use unconditional provider calls. * @module @deepseek-ai/dsh-tool-fs @@ -50,7 +50,7 @@ function assertPositiveInteger(name: string, value: number): void { } } -/** Register the full `read`/`write`/`edit` filesystem tool suite. */ +/** Register the full `read`/`write`/`edit` filesystem tool suite, plus `read_image` while `attachments` is mounted. */ export function apply(ctx: Context, config: Config): void { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index b12bf52e99..f4b4d6e72f 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -60,19 +60,19 @@ export function imageMediaTypeForPath(filePath: string): ImageMediaType | undefi * options) and requires the exact resolved route to declare `image` input explicitly. * @param ctx - the plugin context used to resolve the optional `llm` service. * @param exec - the tool-execution context supplying the calling agent. - * @param displayPath - the path rendered in refusal messages. + * @param requestedPath - the raw, not-yet-resolved path rendered in refusal messages. */ -export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, displayPath: string): Promise { +export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, requestedPath: string): Promise { const routed = exec.agent?.session.requestHeader()?.config const provider = routed?.provider ?? exec.agent?.options.provider const model = routed?.model ?? exec.agent?.options.model const llm = ctx.get('llm') if (provider === undefined || model === undefined || llm === undefined) { - throw new Error(`cannot read "${displayPath}" as an image: the current model route could not be resolved`) + throw new Error(`cannot read "${requestedPath}" as an image: the current model route could not be resolved`) } const active = await llm.resolveModelInfo(provider, model, exec.signal) if (active.inputModalities === undefined || !active.inputModalities.includes('image')) { - throw new Error(`cannot read "${displayPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`) + throw new Error(`cannot read "${requestedPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`) } } @@ -120,12 +120,13 @@ function imageReadContent(value: ImageReadValue): ContentBlock[] { } /** - * Register the `read_image` tool. Execution gates on the optional - * `attachments`/`llm` services and the calling route's declared image input; - * registration itself is unconditional so denial happens at the operation - * boundary rather than through schema omission. - * @param ctx - the plugin context; registrations are effects scoped to it, and - * execution uses its `fs` service plus the optional `attachments`/`llm` services. + * Register the `read_image` tool into the given context. The composing plugin + * owns the attachments gate: `src/index.ts` calls this inside + * `ctx.inject(['attachments'], …)` so the tool exists only while a durable + * store is mounted. Execution still re-checks `ctx.get('attachments')` for + * direct callers and gates on the calling route's declared image input. + * @param ctx - the registration scope; execution uses its `fs` service plus + * the optional `attachments`/`llm` services. */ export function applyReadImageTool(ctx: Context): void { ctx.tools.register(defineTool({ @@ -180,10 +181,16 @@ export function applyReadImageTool(ctx: Context): void { const target = await ctx.fs.resolve(args.file_path, sessionResolveOptions(exec, args.file_path)) const info = await ctx.fs.stat(target, exec.signal) - if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + if (!info) { + ctx.emit('fs/observed', target, { kind: 'absent' }, exec) + throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + } if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') - const data = await ctx.fs.readBytes(target, exec.signal, attachments.imageLimits.maxImageBytes) + // The tool result is one message carrying one image, so the per-message + // aggregate bound applies beside the per-image bound. + const byteCap = Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes) + const data = await ctx.fs.readBytes(target, exec.signal, byteCap) // Persist before returning: the image block must reference a durably // committed object by the time the tool/result event is appended. let ref: ImageAttachmentRef @@ -193,7 +200,7 @@ export function applyReadImageTool(ctx: Context): void { if (!(error instanceof AttachmentError) || error.code !== 'IMAGE_TYPE_MISMATCH') throw error const extension = extname(target.displayPath).toLowerCase() throw new Error( - `cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual PNG/JPEG/WebP/GIF format`, + `cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, { cause: error }, ) } diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 229069a8d2..22946f5978 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -93,7 +93,7 @@ interface SetupOptions { resolvedModels?: LlmModelInfo[] attachments?: boolean llm?: boolean - storeConfig?: { maxImageBytes?: number; maxImagePixels?: number } + storeConfig?: { maxImageBytes?: number; maxImagePixels?: number; maxMessageImageBytes?: number } toolMode?: ToolConfig['mode'] } @@ -366,7 +366,7 @@ describe('image admission failures', () => { const result = await readImage(ctx, { file_path: 'wrong.jpg' }, agentOn('vision-model')) expect(result.isError).toBe(true) expect(text(result)).toContain('the .jpg extension declares image/jpeg') - expect(text(result)).toContain('rename the file to match its actual PNG/JPEG/WebP/GIF format') + expect(text(result)).toContain('rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats') }) it('fails with FS_TOO_LARGE before reading a file past maxImageBytes', async () => { @@ -377,6 +377,14 @@ describe('image admission failures', () => { expect(text(result)).toContain('exceeds') }) + it('honors the tighter per-message aggregate byte bound', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ storeConfig: { maxMessageImageBytes: PNG_1X1.length - 1 } }) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('exceeds') + }) + it('surfaces the pixel limit from the attachment admission', async () => { await writeFile(join(dir, 'big.png'), PNG_3X3) const ctx = await setup({ storeConfig: { maxImagePixels: 4 } }) @@ -387,9 +395,12 @@ describe('image admission failures', () => { it('reports a missing image file and a directory target through the fs vocabulary', async () => { await mkdir(join(dir, 'folder.png')) const ctx = await setup() + const observed: { path: string; kind: string }[] = [] + ctx.on('fs/observed', (target, observation) => void observed.push({ path: target.displayPath, kind: observation.kind })) const missing = await readImage(ctx, { file_path: 'absent.png' }, agentOn('vision-model')) expect(missing.isError).toBe(true) expect(text(missing)).toContain('not found') + expect(observed).toEqual([{ path: join(dir, 'absent.png'), kind: 'absent' }]) const directory = await readImage(ctx, { file_path: 'folder.png' }, agentOn('vision-model')) expect(directory.isError).toBe(true) @@ -430,6 +441,32 @@ describe('image admission failures', () => { }) describe('registration surface', () => { + it('withdraws read_image when the tool-fs fiber or the attachment store is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: 'native' }) + await ctx.plugin(LocalFileSystem, { cwd: dir }) + await ctx.plugin(FsPolicy) + const attachmentsFiber = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) + const toolFsFiber = await ctx.plugin(ToolFs) + const names = () => ctx.tools.schemas().map(schema => schema.name).sort() + expect(names()).toEqual(['edit', 'read', 'read_image', 'write']) + + // Disposing only the attachment store tears down the scoped inject fiber: + // read_image withdraws while the unconditional tools stay registered. + await attachmentsFiber.dispose() + expect(names()).toEqual(['edit', 'read', 'write']) + + // Remounting the store restores the conditional registration. + const remounted = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) + expect(names()).toEqual(['edit', 'read', 'read_image', 'write']) + void remounted + + // Disposing the whole plugin withdraws every tool, read_image included. + await toolFsFiber.dispose() + expect(names()).toEqual([]) + }) + it('declares read_image parallel-safe and presents a read-family card', async () => { const ctx = await setup() expect(ctx.tools.executionMode({ From ea47c3280504c21ba795e2d20b514add43251751 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 16:09:04 +0800 Subject: [PATCH 09/77] review: one fold implementation, uniform end-edge absence, partial text in tool errors, snapshot scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address ds-review-bot on #2127: - assistant-output: the rule has ONE implementation, the incremental AssistantOutputFold (push/pushText/collect); finalAssistantOutput folds a complete suffix, the SDK backend folds notification events, and the ACP backend folds raw chunk text into the same streamed fallback. - subagent/end.lastAssistantMessage: 'no output' is encoded once — absent, never [], on both lifecycle shapes (observeRun now omits empty output). - tool-subagent: a non-completed foreground result stays isError but appends the child's preserved partial text after the stop-reason headline. - Authored keyless snapshot scenario subagent-max-tokens-partial pins the assembled transcript: the child's committed log carries the usage-only empty message and the parent's tool result carries the partial answer. - Rule-boundary sentence (message wins over later streamed text) and the consumer half recorded in the Agent Note; comments trimmed to pointers. --- ...nt-empty-terminal-message-output.i18n.yaml | 4 +- ...-subagent-empty-terminal-message-output.md | 8 +- ...bagent-empty-terminal-message-output.zh.md | 8 +- examples/acp-agent/tests/acp.snapshot.ts | 7 ++ .../subagent-max-tokens-partial/input.json | 14 +++ .../session.1.jsonl | 30 +++++++ .../subagent-max-tokens-partial/session.jsonl | 26 ++++++ .../stdout.expected.jsonl | 4 + packages/subagent/subagent-acp/src/run.ts | 18 ++-- packages/subagent/subagent-dsh-sdk/src/run.ts | 26 ++---- .../subagent/subagent-inprocess/src/index.ts | 4 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/assistant-output.ts | 86 ++++++++++++------- packages/subagent/subagent/src/index.ts | 2 +- packages/subagent/subagent/src/lifecycle.ts | 4 +- .../subagent/tests/assistant-output.spec.ts | 25 +++--- .../subagent/subagent/tests/service.spec.ts | 12 +++ .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 20 ++++- .../tool-subagent/tests/tool-subagent.spec.ts | 3 + 24 files changed, 221 insertions(+), 96 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml index 9ec2d8bb33..537cb88062 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.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-08-10-subagent-empty-terminal-message-output.md -2026-08-10-subagent-empty-terminal-message-output.md: ece2a55930aabfaeaf88ef1757918296b32bfea5 -2026-08-10-subagent-empty-terminal-message-output.zh.md: 00b8f5bd7bd8e71935f68af2c35521f2b2377186 +2026-08-10-subagent-empty-terminal-message-output.md: d90047c07a300a1afbc42c7db1a4fefa25d56764 +2026-08-10-subagent-empty-terminal-message-output.zh.md: 0a5ce02dccef422dc75bc980d104f41f116427f2 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md index ece2a55930..d90047c07a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md @@ -10,9 +10,11 @@ The agent loop appends an EMPTY-content `assistant/message` when a `max-tokens` ## Decision -`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: the last NON-EMPTY assistant message wins; without one, the accumulated `text-delta` stream is the answer; empty-content messages never contribute. `finalAssistantOutput(events)` applies the rule to an event suffix (the in-process `readResult` and the Activation capture), and `assistantMessageOutput(event)` is the same per-event predicate for the SDK backend's incremental fold. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` declares it selects by the same rule. A `max-tokens` or `aborted` finish still reports its honest stop reason; only output selection changed. +`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: the last NON-EMPTY assistant message wins; without one, the accumulated `text-delta` stream is the answer; empty-content messages never contribute. The rule has one implementation, the incremental `AssistantOutputFold` (`push(event)` for session-event transports, `pushText(text)` for chunk-only transports, `collect()` to select), and `finalAssistantOutput(events)` applies it to a complete event suffix (the in-process `readResult` and the Activation capture). The SDK backend folds notification events; the ACP backend, which surfaces no complete assistant messages, folds raw chunk text into the same streamed fallback. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` selects by the same rule, and "no output" has one encoding on that edge — the field is absent, never an empty array, on both the one-shot and continuable lifecycle shapes. A `max-tokens` or `aborted` finish still reports its honest stop reason; only output selection changed. -The ACP backend accumulates chunks only and was never affected. The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message. +The foreground delegation tool observes the same selection: a non-`completed` result stays an `isError` tool result, but its message appends the child's preserved partial text after the stop-reason headline, so the parent model sees the truncated answer instead of a bare failure. + +The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message, and the authored `subagent-max-tokens-partial` ACP snapshot scenario pins the assembled transcript: a scripted child streams text plus a tool call, is cut off by a tool-only max-tokens step (the empty usage-only message appears in its committed log), and the parent's tool result carries the partial answer. ## Alternatives considered @@ -24,4 +26,4 @@ The ACP backend accumulates chunks only and was never affected. The fake SDK run ## Consequences -Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children keep the text streamed before the abort; one-shot and continuable `subagent/end` edges agree with `SubagentResult.output`. A message whose content is non-empty but textless (for example reasoning-only) still wins over streamed text — the rule is about empty content, not text presence. Regression tests in all three packages script the empty-terminal-message and cancel paths and fail under the previous selections. +Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children keep the text streamed before the abort; one-shot and continuable `subagent/end` edges agree with `SubagentResult.output`. A message whose content is non-empty but textless (for example reasoning-only) still wins over streamed text — the rule is about empty content, not text presence. A non-empty message also wins over text streamed AFTER it: a child cancelled while streaming a later step reports its earlier complete message, matching the SDK backend's documented contract, with the stop reason signalling the truncation. Regression tests in all three packages script the empty-terminal-message and cancel paths and fail under the previous selections. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md index 00b8f5bd7b..0a5ce02dcc 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md @@ -10,9 +10,11 @@ Status: implemented ## 决策 -`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:最后一条**非空** assistant 消息优先;没有时,累积的 `text-delta` 流就是回答;空内容消息从不参与。`finalAssistantOutput(events)` 把该规则应用于事件后缀(进程内 `readResult` 与 Activation capture),`assistantMessageOutput(event)` 是同一规则的逐事件谓词,供 SDK 后端的增量折叠使用。契约在 `SubagentResult.output` 处声明一次,并由子系统参考文档镜像;`subagent/end.lastAssistantMessage` 声明按同一规则选取。`max-tokens` 或 `aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。 +`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:最后一条**非空** assistant 消息优先;没有时,累积的 `text-delta` 流就是回答;空内容消息从不参与。规则只有一个实现,即增量的 `AssistantOutputFold`(会话事件传输用 `push(event)`,仅分块传输用 `pushText(text)`,`collect()` 完成选取);`finalAssistantOutput(events)` 把它应用于完整的事件后缀(进程内 `readResult` 与 Activation capture)。SDK 后端折叠通知事件;ACP 后端不产生完整 assistant 消息,因此把原始分块文本折叠进同一个流式兜底。契约在 `SubagentResult.output` 处声明一次,并由子系统参考文档镜像;`subagent/end.lastAssistantMessage` 按同一规则选取,且"无输出"在该边沿只有一种编码——字段缺省,绝不是空数组,一次性与 continuable 两种生命周期形态一致。`max-tokens` 或 `aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。 -ACP 后端只累积分块,从未受影响。fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息。 +前台委派工具观察同一选取结果:非 `completed` 的结果仍是 `isError` 工具结果,但其消息在终止原因标题之后附带子代理保留下来的部分文本,父模型看到的是被截断的回答而不是一句干巴巴的失败。 + +fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息;authored 的 `subagent-max-tokens-partial` ACP snapshot 场景钉住了组装后的 transcript:脚本化的子代理先流式输出文本和一次工具调用,再被仅含工具调用的 max-tokens 步骤截断(空的 usage-only 消息出现在其提交的日志中),父侧工具结果携带部分回答。 ## 考虑过的替代方案 @@ -24,4 +26,4 @@ ACP 后端只累积分块,从未受影响。fake SDK runtime 新增 `FAKE_EMPT ## 后果 -被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。 +被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。非空消息同样优先于**其后**才流式出的文本:子代理在流式后续步骤时被取消,报告的是更早那条完整消息,与 SDK 后端文档化的契约一致,截断由终止原因示意。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..852f9228ff 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -299,6 +299,13 @@ const SCENARIOS: Scenario[] = [ // Windows bash process-tree kill is deferred with the Bash execution domain. { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true }, { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, + // Keyless, authored (like error-finish): a live child cannot be coaxed into + // a max-tokens step that assembled ONLY tool-call blocks — the truncation + // shape whose usage-only empty assistant/message must not erase the child's + // earlier text. The child fixture scripts text + todo_write, then a + // tool-only max-tokens cutoff; the parent's subagent tool result must carry + // the child's real partial answer with the max-tokens stop reason. + { name: 'subagent-max-tokens-partial', hasModelTurn: true, recorded: false }, { name: 'subagent-multi', hasModelTurn: true, recorded: true }, { name: 'subagent-fork', hasModelTurn: true, recorded: true }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json new file mode 100644 index 0000000000..640bf92f7f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl new file mode 100644 index 0000000000..55261e7a95 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl @@ -0,0 +1,30 @@ +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":2,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":1786348800126,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"}]}} +{"type":"turn/start","seq":1,"time":1786348800126,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786348800126,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1786348800139,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Truncated child"}} +{"type":"step/start","seq":4,"time":1786348800142,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1786348800142,"data":{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786348800142,"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":"ff5bdbe7-2eb0-4380-8edb-0e5c58ba9840"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786348800142,"data":{"title":"Write the words 'partial one',","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1786348800142,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1786348800142,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"partial one"}}}} +{"type":"assistant/chunk","seq":12,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}}}} +{"type":"assistant/chunk","seq":14,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":9}}}} +{"type":"assistant/chunk","seq":15,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1786348800146,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial one"},{"type":"tool-call","id":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5e4d07b2-6ce2-4ab6-8be0-fbdf2d3af138"},"usage":{"inputTokens":20,"outputTokens":9}},"sourceEventSeqs":[10,11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1786348800146,"data":{"turn":1,"step":1,"callId":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}} +{"type":"todo/write","seq":18,"time":1786348800150,"data":{"todos":[{"content":"keep going","status":"in_progress"}]}} +{"type":"tool/result","seq":19,"time":1786348800151,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_1"},"content":[{"type":"tool-result","toolCallId":"call_child_1","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"67efbbf3-ca1e-4d23-8f19-940cb391ff1e"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1786348800151,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":21,"time":1786348800156,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":22,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":23,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"completed\"}]}"}}}} +{"type":"assistant/chunk","seq":24,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":25,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"max-tokens"}}}} +{"type":"assistant/message","seq":26,"time":1786348800160,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bb92e4ec-f260-4415-9782-b71147ea378d"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1786348800160,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1786348800160,"data":{"turn":1,"reason":{"kind":"max-tokens"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl new file mode 100644 index 0000000000..57386320e4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786348800078,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"8787ce07-4f1f-4368-bf58-18e30484ed44"}]}} +{"type":"turn/start","seq":1,"time":1786348800079,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786348800079,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786348800114,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786348800114,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"8787ce07-4f1f-4368-bf58-18e30484ed44"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786348800114,"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":"309b09a3-9593-4161-903d-cb5b14d8e9d9"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786348800114,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786348800115,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786348800115,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":12,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1786348800120,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f4269cd2-9132-4b68-8f9b-ff3a40321bc9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1786348800121,"data":{"turn":1,"step":1,"callId":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}} +{"type":"tool/result","seq":15,"time":1786348800163,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parent_1"},"content":[{"type":"tool-result","toolCallId":"call_parent_1","content":[{"type":"text","text":"Error: subagent run hit its token limit before finishing\nPartial output before the run ended:\npartial one"}],"isError":true}],"role":"user","id":"5dd34050-a533-4f1b-99ee-5fc62c6a4502"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1786348800163,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1786348800169,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":20,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":21,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1786348800173,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fb14560d-1d98-4b18-8736-b079de400315"},"usage":{"inputTokens":12,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1786348800173,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1786348800173,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl new file mode 100644 index 0000000000..a460e019d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 17476be41c..c57643411a 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -24,6 +24,7 @@ import { } from '@agentclientprotocol/sdk' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import { AssistantOutputFold } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' @@ -232,8 +233,10 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe let processDisposal: Promise | undefined const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs)) - // Accumulate the child's streamed assistant text — the SubagentResult output. - const output: string[] = [] + // The child's streamed assistant text, accumulated under the seam's + // canonical selection rule (`AssistantOutputFold`); ACP surfaces no complete + // assistant messages, so only the streamed-fallback half applies. + const fold = new AssistantOutputFold() // Shared mutable state keeps cancellation visible across async closures. const flags = { cancelled: false } @@ -241,7 +244,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe sessionUpdate(params: SessionNotification): Promise { const update = params.update if (update.sessionUpdate === 'agent_message_chunk') { - output.push(acpContentText(update.content)) + fold.pushText(acpContentText(update.content)) } // Other updates (thoughts, tool calls, plans) are consumed but not // surfaced — the subagent returns only its final answer. @@ -284,13 +287,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const onAbort = (): void => { requestCancel() } request.signal.addEventListener('abort', onAbort, { once: true }) - // The accumulated child text as harness ContentBlocks (empty array when the - // child streamed nothing). Read at every return so a partial answer survives - // a later cancel/error. - const collectOutput = (): ContentBlock[] => { - const text = output.join('') - return text.length > 0 ? [{ type: 'text', text }] : [] - } + // Read at every return so a partial answer survives a later cancel/error. + const collectOutput = (): ContentBlock[] => fold.collect() ?? [] // Establish the remote session before publishing a handle. Any failure owns // the still-private process and therefore reaps it before rejecting. diff --git a/packages/subagent/subagent-dsh-sdk/src/run.ts b/packages/subagent/subagent-dsh-sdk/src/run.ts index 39bf53fffb..194ce3badf 100644 --- a/packages/subagent/subagent-dsh-sdk/src/run.ts +++ b/packages/subagent/subagent-dsh-sdk/src/run.ts @@ -16,7 +16,7 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk- import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { assistantMessageOutput, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent' +import { AssistantOutputFold, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' /** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */ @@ -163,28 +163,14 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe } const childSessionId = `session-${randomUUID().replaceAll('-', '')}` - // The child's final answer, folded incrementally under the seam's canonical - // rule (`finalAssistantOutput`): the last NON-EMPTY complete assistant - // message when one exists, else the text streamed so far (a partial answer - // surviving cancel). An empty-content message hosts only usage (a max-tokens - // step that assembled no text blocks), so it never erases streamed text. - let lastMessage: ContentBlock[] | undefined - const partial: string[] = [] + // The child's final answer under the seam's canonical selection rule + // (`AssistantOutputFold`); a partial answer survives cancel and error paths. + const fold = new AssistantOutputFold() const observe = (notification: HarnessNotification): void => { if (notification.method !== 'session.event' || notification.params.sessionId !== childSessionId) return - const event = notification.params.event as SessionEvent - const content = assistantMessageOutput(event) - if (content !== undefined) { - lastMessage = content - } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { - partial.push(event.data.chunk.text) - } - } - const collectOutput = (): ContentBlock[] => { - if (lastMessage !== undefined) return lastMessage - const text = partial.join('') - return text.length > 0 ? [{ type: 'text', text }] : [] + fold.push(notification.params.event as SessionEvent) } + const collectOutput = (): ContentBlock[] => fold.collect() ?? [] // Race the child turn against local cancellation; the shared settlement // flattens failures under the seam's never-reject contract. diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index d621674a6e..0f09e8cfa4 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -220,9 +220,7 @@ function readResult( ): SubagentResult { const own = child.session.events.slice(boundary) const lastEnd = findLastMessageTurnEnd(own) - // Canonical selection (`finalAssistantOutput`): the last non-empty assistant - // message, else the text streamed before cancel/error/truncation cut the - // turn short — an empty usage-only message never erases real output. + // The seam's canonical selection rule; a partial answer survives cancel and truncation. const output: ContentBlock[] = finalAssistantOutput(own) ?? [] const recorded = toStopReason(lastEnd?.data.reason) // Disposal can tear the owner down before the loop records its ordinary diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 2b47a91a4b..81fcd7d10b 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: d2d5356fd82a47ecf5cd6b633e5338dde7047901 -README.zh.md: 2fdc3ae6e8376ef7c7aaf11c8e85dd909ef92d2c +README.md: 843fd0af4a86ea3a10d4a4ee101aa05478a2300e +README.zh.md: 497f2a928c8eaeff8ef0486f5344c3d257be7391 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index d2d5356fd8..843fd0af4a 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -56,7 +56,7 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `provider.start(request): Promise` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` edge's `lastAssistantMessage` share one selection rule, implemented by the exported `finalAssistantOutput` helper: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the contract). +`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` edge's `lastAssistantMessage` share one selection rule, implemented once by the exported `AssistantOutputFold`/`finalAssistantOutput` helpers: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the contract). A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 2fdc3ae6e8..497f2a928c 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -56,7 +56,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 边沿的 `lastAssistantMessage` 共用同一条选取规则,由导出的 `finalAssistantOutput` 辅助函数实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 边沿的 `lastAssistantMessage` 共用同一条选取规则,由导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数唯一实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。 diff --git a/packages/subagent/subagent/src/assistant-output.ts b/packages/subagent/subagent/src/assistant-output.ts index 5fea60fa89..5b11031c19 100644 --- a/packages/subagent/subagent/src/assistant-output.ts +++ b/packages/subagent/subagent/src/assistant-output.ts @@ -1,12 +1,12 @@ /** - * Canonical selection of a child's final assistant output from its session - * events. Every surface that reports "the child's answer" — backend run - * results and `subagent/end.lastAssistantMessage` — applies this one rule so - * observers agree: the last NON-EMPTY assistant message wins; an empty-content - * message hosts only usage (the loop appends one when a max-tokens step - * assembled no executable blocks) and never erases real output; without any - * non-empty message, the text streamed so far is the answer (a partial - * surviving cancel, error, and truncation paths). + * Canonical selection of a child's final assistant output. Every surface that + * reports "the child's answer" — backend run results and + * `subagent/end.lastAssistantMessage` — applies this one rule so observers + * agree: the last NON-EMPTY assistant message wins; an empty-content message + * hosts only usage (the loop appends one when a max-tokens step assembled no + * executable blocks) and never erases real output; without any non-empty + * message, the text streamed so far is the answer (a partial surviving + * cancel, error, and truncation paths). * * @module @deepseek-ai/dsh-subagent/assistant-output */ @@ -15,37 +15,57 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' /** - * The content one event contributes as a candidate final answer: an - * `assistant/message` with non-empty content. An empty-content message hosts - * only usage and contributes none. - * @param event - any session event. - * @returns the message content, or `undefined` when this event is not a - * non-empty assistant message. + * Incremental fold of the selection rule, for backends that observe a child's + * output as it streams: session-event backends {@link push} each event, and + * transports without session events (ACP content chunks) {@link pushText} raw + * text into the same streamed fallback. */ -export function assistantMessageOutput(event: SessionEvent): ContentBlock[] | undefined { - if (event.type !== 'assistant/message') return undefined - const content = event.data.message.content - return content.length > 0 ? content : undefined +export class AssistantOutputFold { + private message: ContentBlock[] | undefined + private partial: string[] = [] + + /** + * Fold one session event: a non-empty assistant message becomes the + * candidate final answer, and a `text-delta` chunk extends the streamed + * fallback; every other event contributes nothing. + * @param event - the next observed session event. + */ + push(event: SessionEvent): void { + if (event.type === 'assistant/message') { + const content = event.data.message.content + if (content.length > 0) this.message = content + } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { + this.partial.push(event.data.chunk.text) + } + } + + /** + * Extend the streamed fallback with text observed outside session events. + * @param text - the next streamed text piece (an empty piece is a no-op). + */ + pushText(text: string): void { + this.partial.push(text) + } + + /** + * Select the final output folded so far. + * @returns the last non-empty assistant message, else the accumulated + * streamed text, or `undefined` when the child produced neither. + */ + collect(): ContentBlock[] | undefined { + if (this.message !== undefined) return this.message + const text = this.partial.join('') + return text.length > 0 ? [{ type: 'text', text }] : undefined + } } /** - * Select the final assistant output from one child-owned event suffix: the - * last non-empty assistant message, else the accumulated `text-delta` stream. + * Apply the selection rule to one complete child-owned event suffix. * @param events - the child-owned events (after any seed or epoch boundary). * @returns the selected output, or `undefined` when the child produced none. */ export function finalAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { - let message: ContentBlock[] | undefined - const partial: string[] = [] - for (const event of events) { - const content = assistantMessageOutput(event) - if (content !== undefined) { - message = content - } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { - partial.push(event.data.chunk.text) - } - } - if (message !== undefined) return message - const text = partial.join('') - return text.length > 0 ? [{ type: 'text', text }] : undefined + const fold = new AssistantOutputFold() + for (const event of events) fold.push(event) + return fold.collect() } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index d3e26e6dbc..e39d5eec70 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -69,7 +69,7 @@ import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' -export { assistantMessageOutput, finalAssistantOutput } from './assistant-output.ts' +export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts' export { SubagentRunId } from './types.ts' export type { ContinuableCreateRequest, diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 26fdd54256..df050f5538 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -129,7 +129,9 @@ export function observeRun( emit('subagent/end', { ...identity, stopReason: result.stopReason, - lastAssistantMessage: result.output, + // One encoding for "no output" across both lifecycle shapes: the + // field is absent, matching the continuable epoch edge. + ...result.output.length === 0 ? {} : { lastAssistantMessage: result.output }, }, parent) }, () => { diff --git a/packages/subagent/subagent/tests/assistant-output.spec.ts b/packages/subagent/subagent/tests/assistant-output.spec.ts index 5219209249..2205431aae 100644 --- a/packages/subagent/subagent/tests/assistant-output.spec.ts +++ b/packages/subagent/subagent/tests/assistant-output.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { assistantMessageOutput, finalAssistantOutput } from '../src/assistant-output.ts' +import { AssistantOutputFold, finalAssistantOutput } from '../src/assistant-output.ts' function message(content: ContentBlock[]): SessionEvent { return { type: 'assistant/message', data: { message: { content } } } as SessionEvent @@ -15,15 +15,6 @@ function reasoningDelta(text: string): SessionEvent { return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent } -describe('assistantMessageOutput', () => { - it('returns content only for a non-empty assistant message', () => { - const content: ContentBlock[] = [{ type: 'text', text: 'answer' }] - expect(assistantMessageOutput(message(content))).toBe(content) - expect(assistantMessageOutput(message([]))).toBeUndefined() - expect(assistantMessageOutput(textDelta('chunk'))).toBeUndefined() - }) -}) - describe('finalAssistantOutput', () => { it('selects the last non-empty message past a later empty usage-only message', () => { const events = [ @@ -58,3 +49,17 @@ describe('finalAssistantOutput', () => { expect(finalAssistantOutput([reasoningDelta('thinking'), message([])])).toBeUndefined() }) }) + +describe('AssistantOutputFold', () => { + it('folds raw text pieces into the same streamed fallback (ACP chunk transport)', () => { + const fold = new AssistantOutputFold() + fold.pushText('partial ') + fold.pushText('') + fold.pushText('answer') + expect(fold.collect()).toEqual([{ type: 'text', text: 'partial answer' }]) + }) + + it('collects undefined until any output is folded', () => { + expect(new AssistantOutputFold().collect()).toBeUndefined() + }) +}) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index a50696cf2a..b46f2489fa 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -15,6 +15,7 @@ import SubagentService, { type SubagentProvider, type SubagentResult, type SubagentRun, + type SubagentRunEndInfo, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' @@ -263,6 +264,17 @@ describe('SubagentService', () => { stopReason: 'completed', })) + // "No output" has ONE encoding on the end edge: the field is absent, + // never an empty array, matching the continuable epoch edge. + const silent = new StubProvider('silent', NO_CAPS, { output: [], stopReason: 'completed' }) + subagents.registerProvider(silent) + const silentRun = await subagents.start('silent', baseRequest()) + await silentRun.result + await Promise.resolve() + const silentEnd = ended.mock.calls.map(call => call[0] as SubagentRunEndInfo).find(info => info.provider === 'silent') + expect(silentEnd).toBeDefined() + expect('lastAssistantMessage' in silentEnd!).toBe(false) + const failure = Promise.withResolvers() subagents.registerProvider({ name: 'infra', diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index e5f0c43f61..8c41c6413a 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/subagent/tool-subagent/README.md -README.md: 6ec313b3b97f0ffa7488025d4314b1c6231a6f6a -README.zh.md: 1fd88363b3ade9d57c194580f81295eed139ac50 +README.md: ac3ec0563cce9128608ca31860b034a103dc1a3a +README.zh.md: d64831d7cf64800ad3307ce6cb7f294500a0a6f0 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 6ec313b3b9..ac3ec0563c 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -8,7 +8,7 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. -A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. If result collection and disposal both reject, the errored result preserves both diagnostics. +A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics. With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result, and the child does not report back — its transcript by that id is the source of its output, and the optional global `send_message` tool sends it more work. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 1fd88363b3..d64831d7cf 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -8,7 +8,7 @@ 每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 -前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 +前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子代理保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript(文本记录)即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 67894c32cb..b2743054c4 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -134,6 +134,21 @@ function stopReasonError(result: SubagentResult): string | undefined { } } +/** + * Append the child's preserved partial answer to a stop-reason error so a + * truncated or cancelled child's real text still reaches the parent model. + * @param error - the stop-reason headline. + * @param output - the child's selected output (`SubagentResult.output`). + * @returns the headline, extended with the partial text when any exists. + */ +function withPartialText(error: string, output: ContentBlock[]): string { + const text = output + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join('') + return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}` +} + type ForegroundToolResult = { readonly kind: 'foreground' readonly runId: SubagentRun['id'] @@ -149,8 +164,9 @@ async function settleForegroundRun(run: SubagentRun): Promise { const error = stopReasonError(result) if (error !== undefined) { - // The registry converts this throw to isError; partial output is not success. - throw new Error(error) + // The registry converts this throw to isError; partial output is not + // success, but the preserved partial answer still reaches the parent. + throw new Error(withPartialText(error, result.output)) } return { kind: 'foreground', diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 91dc423cd4..ed83696358 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -154,6 +154,9 @@ describe('dsh-tool-subagent', () => { const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(result.isError).toBe(true) expect(text(result)).toContain(fragment) + // The failure is not partial success, but the child's preserved partial + // answer still reaches the parent model inside the error result. + expect(text(result)).toContain('scripted subagent reply') }) it('registers under a configurable toolName so multiple providers can coexist', async () => { From ccbaedc8a888ddbb3233e7925432e0cdb3c93b77 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 16:29:55 +0800 Subject: [PATCH 10/77] test(scaffold-server): expect absent lastAssistantMessage for a childless result The subagent/end edge now encodes 'no output' as an absent field, never an empty array; the wire projection forwards only present fields. --- packages/scaffold/server/tests/built-scope-carrier.e2e.ts | 3 ++- packages/scaffold/server/tests/server.spec.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts index b3519110b5..fdd5276352 100644 --- a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts +++ b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts @@ -106,6 +106,8 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', ( }) expect(stderr).not.toContain('listener threw') + // A childless result carries NO lastAssistantMessage on the wire: the end + // edge encodes "no output" as an absent field, never `[]`. expect(JSON.parse(stdout) as unknown).toEqual([{ method: 'subagent.finished', params: { @@ -115,7 +117,6 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', ( childSessionId: 'built-child', status: 'ok', stopReason: 'completed', - lastAssistantMessage: [], }, }]) }) diff --git a/packages/scaffold/server/tests/server.spec.ts b/packages/scaffold/server/tests/server.spec.ts index 714fc0ada3..7b375f1a97 100644 --- a/packages/scaffold/server/tests/server.spec.ts +++ b/packages/scaffold/server/tests/server.spec.ts @@ -736,6 +736,8 @@ describe('HarnessSdkServer', () => { stopReason: 'error', }) + // A childless result carries NO lastAssistantMessage on the wire: the + // end edge encodes "no output" as an absent field, never `[]`. expect(transport.notifications).toContainEqual({ method: 'subagent.finished', params: { @@ -745,7 +747,6 @@ describe('HarnessSdkServer', () => { childSessionId: 'fallback-child-session', status: 'ok', stopReason: 'max-tokens', - lastAssistantMessage: [], }, }) expect(transport.notifications).toContainEqual({ From 0a4bdd8ee806a0eeaaf523dba3057717d710f58a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 17:06:07 +0800 Subject: [PATCH 11/77] docs(subagent): note the settlement-fold optimization condition --- packages/subagent/subagent/src/assistant-output.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/subagent/subagent/src/assistant-output.ts b/packages/subagent/subagent/src/assistant-output.ts index 5b11031c19..a617060390 100644 --- a/packages/subagent/subagent/src/assistant-output.ts +++ b/packages/subagent/subagent/src/assistant-output.ts @@ -65,6 +65,10 @@ export class AssistantOutputFold { * @returns the selected output, or `undefined` when the child produced none. */ export function finalAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { + // TODO: this folds the complete suffix once per run/epoch settlement. If a + // long continuable epoch ever profiles hot here, scan backward with early + // exit for the last non-empty message and fold text deltas only on the + // no-message fallback. const fold = new AssistantOutputFold() for (const event of events) fold.push(event) return fold.collect() From 528c776e1b54fb047f1ccf3d6ecef57dacb0d1b3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 19:11:06 +0800 Subject: [PATCH 12/77] fix(e2b): release bounded read stream locks Release the Web Stream reader after either completion or cancellation. Strengthen the oversized-file test so a regression that downloads content before applying the stat bound fails directly. --- packages/e2b/fs-e2b/src/index.ts | 1 + packages/e2b/fs-e2b/tests/filesystem.spec.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index 75005fa3ad..eda6866116 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -274,6 +274,7 @@ export class E2BFileSystem extends FileSystem { // remote stream adds nothing actionable for the caller. } } + reader.releaseLock() } const whole = new Uint8Array(bytes) let offset = 0 diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 648db7b105..a2a494911f 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -40,6 +40,7 @@ class FakeRemote { readonly links: Array<{ from: string; to: string }> = [] readonly removals: string[] = [] readonly commands: string[] = [] + readonly reads: Array<{ path: string; format: 'bytes' | 'stream' }> = [] streamChunks: Uint8Array[] | undefined streamKeepOpen = false readonly streamCancel = vi.fn() @@ -157,6 +158,7 @@ class FakeRemote { }, read: async (path: string, options: { format: 'bytes' | 'stream'; signal?: AbortSignal }): Promise | string> => { this.checkAbort(options) + this.reads.push({ path, format: options.format }) if (this.nextReadError !== undefined) { const error = this.nextReadError this.nextReadError = undefined @@ -478,7 +480,10 @@ describe('E2BFileSystem identity, metadata, and reads', () => { const { fs } = await setup(remote) const target = await fs.resolve('img.bin') expect(Array.from(await fs.readBytes(target, undefined, 4))).toEqual([0x89, 0, 0xff, 0x47]) + expect(remote.reads).toEqual([{ path: '/workspace/img.bin', format: 'stream' }]) + remote.reads.length = 0 await expectCode(fs.readBytes(target, undefined, 3), 'FS_TOO_LARGE') + expect(remote.reads).toEqual([]) await expectCode(fs.readBytes(await fs.resolve('missing'), undefined, 4), 'FS_NOT_FOUND') await expectCode(fs.readBytes(await fs.resolve('directory'), undefined, 4), 'FS_NOT_REGULAR_FILE') From 21ec0fd10b22f61e808f587cced60b93f0cf4ce3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 19:32:37 +0800 Subject: [PATCH 13/77] test(cli): expect read_image in standard preset --- apps/cli/tests/web-agent-presets.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1bfaed8c67..43e63fc15a 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -133,7 +133,7 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill', + 'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill', 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search', 'workflow', 'write', From 767e5a57236b3828171db766e694d323cc68e2cd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 19:56:18 +0800 Subject: [PATCH 14/77] fix(fs): close read-image review gaps --- packages/fs/fs/README.i18n.yaml | 4 +-- packages/fs/fs/README.md | 2 +- packages/fs/fs/README.zh.md | 2 +- packages/fs/tool-fs/tests/read-image.spec.ts | 2 +- packages/support/llm-replay/README.i18n.yaml | 4 +-- packages/support/llm-replay/README.md | 2 +- packages/support/llm-replay/README.zh.md | 2 +- packages/support/llm-replay/src/index.ts | 17 +++++++++++++ .../llm-replay/tests/llm-replay.spec.ts | 25 +++++++++++++++++-- 9 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index 9eb2da496a..c9ae290b78 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/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/fs/fs/README.md -README.md: 4e3a37b997c9d377ebb3795f61f9070a8a9c38ea -README.zh.md: 50551d3752acf4ffc12ae40c711c9289fd4d87d7 +README.md: d53fe69456622e533e5ba5a96bd9dab10c188eaa +README.zh.md: 64b7d79687a0b6a0d81a5037ea6044d4a406f32b diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 4e3a37b997..d53fe69456 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -48,7 +48,7 @@ This package declares three events (see the generated region of [filesystem.md]( ## Vocabulary -`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsObservation` distinguishes `{ kind: 'present', version }` from `{ kind: 'absent' }`, so a policy can separate an unseen target from confirmed absence without performing I/O. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. +`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsObservation` distinguishes `{ kind: 'present', version }` from `{ kind: 'absent' }`, so a policy can separate an unseen target from confirmed absence without performing I/O. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_TOO_LARGE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. ## Model Experience diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index 50551d3752..64b7d79687 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -48,7 +48,7 @@ ## 词汇 -`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsObservation` 区分 `{ kind: 'present', version }` 与 `{ kind: 'absent' }`,使策略无需执行 I/O 即可分辨未见目标和确认缺失。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`。 +`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsObservation` 区分 `{ kind: 'present', version }` 与 `{ kind: 'absent' }`,使策略无需执行 I/O 即可分辨未见目标和确认缺失。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_TOO_LARGE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`。 ## 模型体验 diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 22946f5978..5f8ca7205c 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -460,11 +460,11 @@ describe('registration surface', () => { // Remounting the store restores the conditional registration. const remounted = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) expect(names()).toEqual(['edit', 'read', 'read_image', 'write']) - void remounted // Disposing the whole plugin withdraws every tool, read_image included. await toolFsFiber.dispose() expect(names()).toEqual([]) + await remounted.dispose() }) it('declares read_image parallel-safe and presents a read-family card', async () => { diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index 4cef8ce1c7..688e259468 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/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/support/llm-replay/README.md -README.md: ade19b66f96ef26119944cbebba23c43e4b2117c -README.zh.md: 4752e03b04be175d7f3e37fc57fe9c74e1f8a303 +README.md: 6119407c06cf734166300f5f70a3fe65d026d08d +README.zh.md: 75d74b654a9a53e7ada5cc854e275eadcbe01284 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index ade19b66f9..6119407c06 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -29,7 +29,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s | `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). | | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional `ReplayOverrideDoc` sidecar for the primary session: a bare `ReplayEntry[]` replaces its derived script, while `{ patches }` augments it by call index. | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. | -| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow` and declared `inputModalities` (so a scenario can exercise capability gates such as the image-capable `read_image` route check); configured routes dispatch through the replay adapter and never perform provider I/O. | +| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow` and an `inputModalities` array containing only `text` and `image`; invalid modalities fail during plugin loading. Configured routes dispatch through the replay adapter and never perform provider I/O. | | `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. | ```yaml diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index 4752e03b04..75d74b654a 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -29,7 +29,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as | `file` | string | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径。必需(配置或 env)。 | | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` 伴随文件:裸 `ReplayEntry[]` 替换其派生脚本,`{ patches }` 则按调用索引增补该脚本。 | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES`(以路径分隔符分隔) | 嵌套场景中已记录的 subagent 子会话日志;单会话场景为空。 | -| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow` 和声明的 `inputModalities`(让场景能够触发能力门禁,例如 `read_image` 的图像路由检查);已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | +| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow` 和仅包含 `text`、`image` 的 `inputModalities` 数组;模态配置无效时,插件加载会失败。已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | | `paceMs` | number | 无(突发) | 可选的每分片毫秒延迟,使下游传输(例如真实浏览器观察到的 Web SSE(Server-Sent Events)多路复用器)看到真正的增量传递。它只是仿真开关,测试不得依赖它保证正确性。值必须是非负整数;pace 等待期间中止会迅速取消流。 | ```yaml diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 416c2386fd..8c0f574453 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -765,11 +765,28 @@ export interface Config { paceMs?: number } +function validateConfiguredModalities(providers: ReplayProviderConfig[] | undefined): void { + for (const provider of providers ?? []) { + for (const model of provider.models ?? []) { + const modalities: unknown = model.inputModalities + if (modalities === undefined) continue + if (!Array.isArray(modalities) + || !modalities.every((modality: unknown) => modality === 'text' || modality === 'image')) { + throw new Error( + `llm-replay: provider "${provider.id}" model "${model.id}" inputModalities ` + + 'must be an array containing only "text" and "image"', + ) + } + } + } +} + export function apply(ctx: Context, config: Config = {}): void { const file = config.file ?? process.env.DSH_SNAPSHOT_FILE if (file === undefined || file.length === 0) { throw new Error('llm-replay: a fixture path is required (Config.file or $DSH_SNAPSHOT_FILE)') } + validateConfiguredModalities(config.providers) const overrideFile = config.overrideFile ?? process.env.DSH_SNAPSHOT_OVERRIDE const childEnv = process.env.DSH_SNAPSHOT_CHILD_FILES const childFiles = config.childFiles diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 0c36dca1ff..1eb4f0398b 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -7,6 +7,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import { CompactionId } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { + type Config, type ReplayEntry, type SessionScript, apply, @@ -1097,11 +1098,31 @@ describe('apply (the plugin entry)', () => { writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) - apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }], paceMs: 1 }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }]) + apply(ctx, { + file, + providers: [ + { id: 'm', models: [{ id: 'm', inputModalities: ['image'] }, { id: 'text' }] }, + { id: 'empty' }, + ], + paceMs: 1, + }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }, { id: 'empty', name: 'empty' }]) + await expect(ctx.llm.resolveModelInfo('m', 'm')).resolves.toMatchObject({ inputModalities: ['image'] }) expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) }) + it.each([ + ['a string', 'image'], + ['an unknown modality', ['audio']], + ])('rejects inputModalities configured as %s during load', (_case, inputModalities) => { + const ctx = new Context() + const providers = [{ id: 'm', models: [{ id: 'm', inputModalities }] }] as unknown as + NonNullable + expect(() => { apply(ctx, { file, providers }) }).toThrow( + 'llm-replay: provider "m" model "m" inputModalities must be an array containing only "text" and "image"', + ) + }) + it('falls back to $DSH_SNAPSHOT_FILE / $DSH_SNAPSHOT_OVERRIDE when config is empty', async () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') From 00f68d7e9339105184d7f13dcf89a17ce80f3fe5 Mon Sep 17 00:00:00 2001 From: lintianle Date: Mon, 10 Aug 2026 18:50:03 +0800 Subject: [PATCH 15/77] feat(mcp-client): auto-reconnect with bounded backoff after transport close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-instance connection supervisor restarts the original server config with exponential backoff when the transport closes, re-runs tool discovery on success, and atomically replaces the previous generation. Default policy retries for ~2.5 minutes (10 attempts, 500ms→30s doubling) before giving up and unregistering the server's tools. New config block reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts } on both transports; misconfiguration fails plugin load. A connection that survives past the stability window (maxDelayMs) resets the attempt budget, so occasional crashes recover indefinitely while a crash loop still exhausts the cap. Integrates with the upstream failOnStartupError: the initial sync uses registrationFailure:'throw' when that flag is set so a squatted namespace still rejects activation. Fixes #1746 --- .../2026-07-07-mcp-client-plugin.i18n.yaml | 4 +- .../feature/2026-07-07-mcp-client-plugin.md | 12 +- .../2026-07-07-mcp-client-plugin.zh.md | 12 +- ...-08-06-mcp-client-auto-reconnect.i18n.yaml | 6 + .../2026-08-06-mcp-client-auto-reconnect.md | 48 ++ ...2026-08-06-mcp-client-auto-reconnect.zh.md | 48 ++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 18 +- docs/config-catalog.zh.md | 16 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/mcp/mcp-client/README.i18n.yaml | 4 +- packages/mcp/mcp-client/README.md | 14 +- packages/mcp/mcp-client/README.zh.md | 14 +- packages/mcp/mcp-client/package.json | 2 + packages/mcp/mcp-client/src/connection.ts | 292 +++++++++++++ packages/mcp/mcp-client/src/index.ts | 90 ++-- packages/mcp/mcp-client/tests/apply.spec.ts | 31 +- .../mcp/mcp-client/tests/fixture-server.ts | 11 + .../mcp/mcp-client/tests/mcp-client.e2e.ts | 83 +++- .../mcp/mcp-client/tests/reconnect.spec.ts | 413 ++++++++++++++++++ packages/mcp/mcp-client/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 24 files changed, 1044 insertions(+), 94 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md create mode 100644 packages/mcp/mcp-client/src/connection.ts create mode 100644 packages/mcp/mcp-client/tests/reconnect.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index ca61a3783c..678ae169cf 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.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-07-mcp-client-plugin.md -2026-07-07-mcp-client-plugin.md: 756a5c4dc9f1152ecb1955d93b8dc47fcf07c661 -2026-07-07-mcp-client-plugin.zh.md: 9f45203c479379087c5a19eccce3ab7a339d4ed0 +2026-07-07-mcp-client-plugin.md: 077d978d8815d759574f89ed524ab3bc8c24a267 +2026-07-07-mcp-client-plugin.zh.md: 8f58c9359ca8447717cc353f97f1333370fa6fda diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index 756a5c4dc9..077d978d88 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -153,13 +153,7 @@ Build the child environment from the subprocess seam's shared `scrubbedParentEnv ### Disconnection / crash -No auto-reconnect. If the MCP server process exits or the transport closes: - -1. The effect disposes → all registered tools are unregistered (fiber-scoped disposers). -2. Subsequent model calls to those tools → `ToolNotFoundError` → `isError: true`. -3. Recovery: user edits `cordis.yml` (triggers HMR reload) or restarts the harness. - -This matches the ACP subagent pattern: "crash = terminal, report error, clean up, don't retry." +A per-instance connection supervisor reconnects automatically after a lost connection with bounded exponential backoff and a per-outage attempt budget, re-running discovery on success; exhaustion unregisters the server's tools and stops until reload. The [auto-reconnect Agent Note](2026-08-06-mcp-client-auto-reconnect.md) owns that decision, including the `reconnect` config block and the `reconnect.enabled: false` opt-out that restores manual HMR/restart recovery. ## Alternatives considered @@ -173,7 +167,7 @@ Rejected. There is no foreseeable alternative MCP client implementation — MCP ### Auto-reconnect with exponential backoff -Rejected for v1. Adds complexity (partial-availability state where tools are registered but temporarily non-functional), and stdio process crashes usually indicate a configuration problem that retrying won't fix. HMR already provides the manual recovery path. Can be added as a future `reconnect: boolean` config if needed. +Rejected for v1: it added a partial-availability state (tools registered but temporarily non-functional), and stdio crashes often indicate configuration problems retrying cannot fix; HMR was the recovery path. Operational feedback reversed the deferral — the [auto-reconnect Agent Note](2026-08-06-mcp-client-auto-reconnect.md) implements it with a bounded per-outage budget and an opt-out. ### Bridge Resources and Prompts @@ -211,4 +205,4 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex - **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving; breaking changes require updating the bridge. The version is pinned, and the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent. - **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out; that is the server author's responsibility, not the bridge's. - **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. The Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level. -- Crash recovery is manual (HMR edit or restart) — accepted for v1; a `reconnect` config remains open as future work. +- Crash recovery is automatic within the [reconnect budget](2026-08-06-mcp-client-auto-reconnect.md); manual reload remains the path after exhaustion or with `reconnect.enabled: false`. diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 9f45203c47..8f58c9359c 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -153,13 +153,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp ### 断连 / 崩溃 -不自动重连。如果 MCP 服务器进程退出或传输层关闭: - -1. effect dispose → 所有已注册工具被注销(fiber 作用域的 disposer)。 -2. 后续模型对这些工具的调用 → `ToolNotFoundError` → `isError: true`。 -3. 恢复:用户编辑 `cordis.yml`(触发 HMR 重载)或重启 harness。 - -这与 ACP subagent 模式一致:「崩溃即终态,报告错误,清理资源,不重试。」 +每个实例的连接监督器在连接丢失后以有界指数退避和单次故障尝试预算自动重连,成功后重新执行发现流程;尝试耗尽则注销该服务器的工具并停止,直到重新加载。[自动重连 Agent Note](2026-08-06-mcp-client-auto-reconnect.md) 拥有该决策,包括 `reconnect` 配置块和恢复手动 HMR/重启恢复的 `reconnect.enabled: false` opt-out。 ## 曾考虑的替代方案 @@ -173,7 +167,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp ### 指数退避自动重连 -v1 否决。引入复杂性(工具已注册但暂时不可用的部分可用状态),且 stdio 进程崩溃通常表明配置问题,重试无法修复。HMR 已提供手动恢复路径。如有需要,可在未来作为 `reconnect: boolean` 配置项添加。 +v1 否决:引入了部分可用状态(工具已注册但暂时不可用),且 stdio 崩溃往往表明配置问题,重试无法修复;HMR 曾是恢复路径。运营反馈扭转了该延期决定——[自动重连 Agent Note](2026-08-06-mcp-client-auto-reconnect.md) 以有界的单次故障预算和 opt-out 实现了自动重连。 ### 桥接 Resources 和 Prompts @@ -211,4 +205,4 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha - **MCP SDK 稳定性**:`@modelcontextprotocol/sdk` 仍在演进中;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。 - **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。 - **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 具有有界的完全停稳过程;卡住的传输层最终会在框架层面超时。 -- 崩溃恢复是手动的(HMR 编辑或重启)——v1 已接受;`reconnect` 配置作为未来工作保持开放。 +- 崩溃恢复在[重连预算](2026-08-06-mcp-client-auto-reconnect.md)内自动进行;耗尽后或配置 `reconnect.enabled: false` 时回退为手动重新加载。 diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml new file mode 100644 index 0000000000..d9ec007b53 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.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-06-mcp-client-auto-reconnect.md +2026-08-06-mcp-client-auto-reconnect.md: b187f5de71a10ca3121d817f383a98669246c81f +2026-08-06-mcp-client-auto-reconnect.zh.md: a4ec6897c34f2142ceb95abecd13d189c4a95624 diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md new file mode 100644 index 0000000000..b187f5de71 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md @@ -0,0 +1,48 @@ +# Agent Note: MCP client auto-reconnect with bounded backoff + +Status: implemented + +English | [中文](2026-08-06-mcp-client-auto-reconnect.zh.md) + +## Problem + +The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. When a stdio server crashed or was killed, its registered tools stayed visible but every call failed with `Not connected` until a human edited the config (HMR) or restarted the Host — v1 explicitly deferred reconnection. Long-running hosts (ACP automation, web) cannot be bounced because a child process died, and for stdio the harness composition is the only party that can respawn it. External feedback escalated this as a real operational gap (issue #1746). + +## Decision + +`packages/mcp/mcp-client/src/connection.ts` owns a per-instance connection supervisor; `apply()` shrinks to config resolution plus two effects (the `serverName` reservation and the supervisor's lifecycle). The supervisor owns the client/transport generations, the live tool registrations, and the reconnect loop. + +**Trigger.** The supervisor arms `client.onclose` per generation. The SDK fires it when the stdio child exits, so a crash is observed without polling. `StreamableHTTPClientTransport` fires `onclose` only for deliberate closes — it owns its internal SSE-stream recovery and surfaces request failures per call — so HTTP servers are effectively outside supervisor restarts; the package README records that limitation. + +**Generations without interleaving.** Each attempt builds a fresh transport and `Client` (the SDK binds a Protocol to one transport for life). One per-supervisor queue serializes every `syncTools` call — initial syncs and `list_changed` re-syncs across all generations — and an `isCurrent` fence makes stale generations inert, so no two syncs can interleave the dispose-previous/register-next swap (which would double-dispose one generation and leak another). The queue also closes a pre-existing race where two rapid `list_changed` notifications re-synced concurrently. Failure signals are idempotent per generation: a connect rejection racing its own transport close schedules exactly one retry. + +**Bounded backoff with an outage budget.** Delays double from `initialDelayMs` up to `maxDelayMs`. One outage shares `maxAttempts` consecutive failed attempts; exhaustion unregisters the server's tools, logs at error level, and stops until disposal or reload. A connection that survives past the stability window — `maxDelayMs`, derived rather than a fifth tunable, as the longest configured backoff spacing — resets the budget, so an occasionally-crashing server recovers indefinitely while a crash loop whose connects briefly succeed cannot launder its budget into a restart storm. + +**Config and resolution.** Both transports accept `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` with schemastery defaults (on, 500ms, 30s, 10). `resolveReconnectPolicy()` is the explicit resolve step: it re-judges every bound and cross-field constraint because programmatic construction may bypass Schemastery, and misconfiguration fails the plugin instance at load. + +**Observable states.** Reconnecting logs at warn with attempt count and delay, recovery at info, final failure and disabled-loss at error. During an outage the last good generation stays registered and calls against it fail — deterministic public names mean a recovered unchanged tool list reproduces identical definitions, keeping the model-visible schema prefix stable instead of flapping. With `reconnect.enabled: false` a lost connection keeps the v1 manual-recovery behavior. + +**Disposal.** Dispose flips the fence, cancels any pending timer, closes the current client, then awaits the in-flight attempt and the sync queue before unregistering — quiescence, not just a request to stop. The reconnect timer is unref'd so a waiting backoff never holds a finishing process open. + +## Alternatives considered + +**Consecutive-failure counter that resets on every successful connect.** Rejected: a crash-looping server whose connects briefly succeed would reset the budget each cycle and restart forever — exactly the restart storm the failure cap exists to prevent. The uptime-gated reset distinguishes a recovered server from a looping one without new configuration. + +**Reuse one SDK `Client` across reconnects.** The Protocol clears its transport on close and can technically connect again, but the SDK's own guidance is one connection per Protocol instance, and reuse carries notification handlers and negotiated capability state across server incarnations. A fresh `Client` per generation plus the `isCurrent` fence is unambiguous. + +**Unregister tools immediately on disconnect, re-register on recovery.** Rejected: a transient outage would flap the model-visible tool list (two schema-prefix invalidations per crash) for no information gain; failing calls already signal the outage, and the swap on recovery is atomic per generation. Tools are unregistered at final failure so a permanently dead server does not leak permanently broken tools. + +**Route Streamable HTTP request failures into the supervisor.** Rejected for now: the HTTP transport already reconnects its SSE stream with its own backoff, per-request errors do not imply a dead server, and there is no child process the harness could respawn. Transport close stays the single trigger. + +**Restart through Loader/HMR machinery instead of an in-plugin supervisor.** Rejected: the Loader owns config-driven recomposition, not runtime health. A plugin restarting itself through the Loader would conflate config generations with connection generations and lose the per-outage budget. + +## Testing + +Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps the v1 behavior, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent. + +## Consequences + +- A crashed stdio MCP server recovers without human intervention: bounded backoff, re-discovery, atomic generation swap. Default policy retries an outage for roughly 2.5 minutes before giving up. +- Connection state is genuinely more intricate than connect-once — the partial-availability window v1 avoided now exists (registered tools failing during an outage), concentrated in one module with the invariants named. +- `reconnect` is new config surface on both transports, and the stability window is deliberately derived from `maxDelayMs`; making it independently tunable is a compatible future change. +- After final failure or with reconnect disabled, the plugin stays loaded with no (or failing) tools until reload — deliberate and logged, so a chronically broken server cannot restart forever. diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md new file mode 100644 index 0000000000..a4ec6897c3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md @@ -0,0 +1,48 @@ +# Agent Note: MCP client auto-reconnect with bounded backoff + +Status: implemented + +[English](2026-08-06-mcp-client-auto-reconnect.md) | 中文 + +## 问题 + +[MCP 客户端](2026-07-07-mcp-client-plugin.md)在插件加载时仅连接一次。stdio 服务器崩溃或被终止后,其已注册的工具仍然可见,但每次调用均以 `Not connected` 失败,直到人工编辑配置触发 HMR(热模块替换)重载,或重启 Host——v1 明确推迟了重连机制。长时间运行的 Host(ACP 自动化、Web)不能因为子进程死亡就被重启;而对于 stdio 传输,harness 组合层是唯一能重新拉起子进程的一方。外部反馈将此升级为真实的运维缺口(issue #1746)。 + +## 决策 + +`packages/mcp/mcp-client/src/connection.ts` 拥有一个逐实例的连接监督器;`apply()` 收缩为配置解析加两个副作用(`serverName` 预留和监督器的生命周期)。监督器负责管理 client/transport 代、活跃的工具注册以及重连循环。 + +**触发条件。** 监督器在每一代上挂载 `client.onclose`。SDK 在 stdio 子进程退出时触发该回调,因此崩溃无需轮询即可感知。`StreamableHTTPClientTransport` 仅在主动关闭时触发 `onclose`——它内部拥有自己的 SSE(Server-Sent Events)流恢复机制,并将请求失败以逐调用方式暴露——因此 HTTP 服务器实际上不在监督器的重启范围内;包 README 记录了该限制。 + +**代隔离,无交错。** 每次尝试构建一个全新的 transport 和 `Client`(SDK 将一个 Protocol 绑定到一个 transport 上终身使用)。每个监督器内部有一个队列将所有 `syncTools` 调用串行化——跨所有代的初始同步和 `list_changed` 再同步——`isCurrent` 栅栏使过时的代变为惰性,从而确保不会有两次同步交错执行 dispose 上一代/注册下一代的切换(否则会对同一代执行两次 dispose 并泄漏另一代)。该队列还消除了一个先前存在的竞态:两次快速的 `list_changed` 通知同时触发重新同步。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。 + +**有界退避与故障预算。** 延迟从 `initialDelayMs` 起逐次翻倍,上限为 `maxDelayMs`。一次故障期间共享 `maxAttempts` 次连续失败尝试的预算;耗尽后注销该服务器的工具、以 error 级别记录日志并停止,直到 dispose 或重新加载。连接在存活超过稳定窗口——即 `maxDelayMs`,作为最长退避间隔从配置推导得出而非作为第五个独立调参项——之后重置预算;因此偶尔崩溃的服务器可无限恢复,而连接短暂成功后立即再次崩溃的循环无法将其预算洗白为重启风暴。 + +**配置与解析。** 两种传输均接受 `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` 配置,Schemastery 默认值为(启用、500ms、30s、10)。`resolveReconnectPolicy()` 是显式的解析步骤:它重新校验每个边界值和跨字段约束,因为程序化构造可能绕过 Schemastery,配置错误在加载时即令插件实例失败。 + +**可观测状态。** 重连中以 warn 级别记录尝试次数和延迟,恢复以 info 级别记录,最终失败和禁用状态下的断连以 error 级别记录。故障期间,上一个正常代保持注册,对其工具的调用返回失败——确定性公开名称意味着恢复后未变化的工具列表会复现相同的定义,保持模型可见 schema 前缀稳定而非反复抖动。设置 `reconnect.enabled: false` 后,断连保持 v1 的手动恢复行为。 + +**资源释放。** dispose 翻转栅栏、取消待执行的定时器、关闭当前 client,然后等待正在进行的尝试和同步队列完成后再注销工具——完全停稳,而非仅发出停止请求。重连定时器使用 unref,因此等待中的退避不会阻止进程正常退出。 + +## 曾考虑的替代方案 + +**连续失败计数器,每次成功连接即重置。** 否决:连接短暂成功后立即崩溃的循环服务器会在每个周期重置预算并永远重启——恰恰是失败上限旨在防止的重启风暴。基于运行时间的重置能区分已恢复的服务器与循环崩溃的服务器,无需新增配置。 + +**跨重连复用同一个 SDK `Client`。** Protocol 在关闭时清除其 transport,技术上可以再次连接,但 SDK 自身的指导方针是每个 Protocol 实例对应一次连接,且复用会将通知处理器和已协商的能力状态带入新的服务器实例。每代创建全新 `Client` 加 `isCurrent` 栅栏的方式无歧义。 + +**断连时立即注销工具,恢复时重新注册。** 否决:短暂故障会使模型可见工具列表抖动(每次崩溃触发两次 schema 前缀失效),而无任何信息增益;失败的调用已足以标示故障,恢复时的切换按代原子执行。工具仅在最终失败时注销,确保永久死亡的服务器不会泄漏永久失效的工具。 + +**将 Streamable HTTP 请求失败路由到监督器。** 暂不采纳:HTTP 传输已使用自己的退避机制重连其 SSE 流,逐请求错误并不意味着服务器已死,且 harness 没有可重新拉起的子进程。transport 关闭仍是唯一触发条件。 + +**通过 Loader/HMR 机制重启,而非使用插件内监督器。** 否决:Loader 负责配置驱动的重组合,而非运行时健康管理。插件通过 Loader 重启自身会混淆配置代与连接代,并丢失逐故障预算。 + +## 测试 + +单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 + +## 后果 + +- 崩溃的 stdio MCP 服务器无需人工干预即可恢复:有界退避、重新发现、原子代切换。默认策略对一次故障大约重试 2.5 分钟后放弃。 +- 连接状态确实比一次性连接更复杂——v1 刻意回避的部分可用窗口现已存在(故障期间已注册工具返回失败),集中在一个模块中并命名了所有不变式。 +- `reconnect` 是两种传输上的新配置表面,稳定窗口刻意从 `maxDelayMs` 推导;将其设为独立可调参数是兼容的未来变更。 +- 最终失败后或禁用重连时,插件保持加载状态但无(或失败的)工具,直到重新加载——行为是刻意的且有日志记录,确保长期故障的服务器不能永远重启。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09c961ef69..6a90ab01d5 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: 76f1d0344d3e172416af36eff27bd4cb8544ed4a +config-catalog.zh.md: d5752a1922d8b8b2e70f055ec05117ed20c445bf diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51c6ae46ee..76f1d0344d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1069,6 +1069,8 @@ export interface StdioConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ @@ -1089,10 +1091,24 @@ export interface StreamableHttpConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig +} + +/** Automatic reconnect policy for one MCP server connection. */ +export interface ReconnectConfig { + /** Reconnect automatically after a lost connection (default true). */ + enabled?: boolean + /** First reconnect delay in milliseconds; doubles per consecutive failed attempt (default 500). */ + initialDelayMs?: number + /** Backoff ceiling in milliseconds; also the uptime after which the attempt budget resets (default 30000). */ + maxDelayMs?: number + /** Consecutive failed attempts per outage before giving up for good (default 10). */ + maxAttempts?: number } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:100`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:104`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-permission` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dc93f5b4b5..d5752a1922 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1071,6 +1071,8 @@ export interface StdioConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ @@ -1091,6 +1093,20 @@ export interface StreamableHttpConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig +} + +/** Automatic reconnect policy for one MCP server connection. */ +export interface ReconnectConfig { + /** Reconnect automatically after a lost connection (default true). */ + enabled?: boolean + /** First reconnect delay in milliseconds; doubles per consecutive failed attempt (default 500). */ + initialDelayMs?: number + /** Backoff ceiling in milliseconds; also the uptime after which the attempt budget resets (default 30000). */ + maxDelayMs?: number + /** Consecutive failed attempts per outage before giving up for good (default 10). */ + maxAttempts?: number } ``` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d478f79cd1..349fadadec 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: 78beee5f9311ae4c75a9ac291d1c7882914bfb8f +module-graph.zh.md: 6b71b0997ce542d53285cd815277556dba46b353 diff --git a/docs/module-graph.md b/docs/module-graph.md index 6763ca6e84..78beee5f93 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -920,6 +920,7 @@ flowchart TD pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_subprocess + pkg_mcp_client --> pkg_timeout pkg_mcp_client --> pkg_tools pkg_tool_bash_persistent --> pkg_agent pkg_tool_bash_persistent --> pkg_invariants @@ -1403,7 +1404,7 @@ flowchart TD | [`timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/interaction/user-interaction) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/self-modification/tool-cordis) | `self-modification` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 339345b39a..6b71b0997c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -922,6 +922,7 @@ flowchart TD pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_subprocess + pkg_mcp_client --> pkg_timeout pkg_mcp_client --> pkg_tools pkg_tool_bash_persistent --> pkg_agent pkg_tool_bash_persistent --> pkg_invariants @@ -1405,7 +1406,7 @@ flowchart TD | [`timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/interaction/user-interaction) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/self-modification/tool-cordis) | `self-modification` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 9568c08454..c66d8ffba8 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-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/mcp/mcp-client/README.md -README.md: 76d1271f6f7a3e9c959bdcf5e969906f25563c56 -README.zh.md: b2da1119af3a8d52761a5040059e7a9d922aa567 +README.md: 97ac9c173fc2b848f524e8c0fdd93eca072567af +README.zh.md: 1b5b5c523e0a477db30f97a748651dbe7e6992ea diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 76d1271f6f..97ac9c173f 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -45,6 +45,10 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same | `headers` | http | no | Extra headers (e.g. auth tokens) | | `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) | | `failOnStartupError` | both | no | Reject plugin activation when initial connection or tool synchronization fails (default `false`) | +| `reconnect.enabled` | both | no | Reconnect automatically after a lost connection (default `true`) | +| `reconnect.initialDelayMs` | both | no | First reconnect delay in ms; doubles per consecutive failed attempt (default 500) | +| `reconnect.maxDelayMs` | both | no | Backoff ceiling in ms; also the uptime after which the attempt budget resets (default 30000) | +| `reconnect.maxAttempts` | both | no | Consecutive failed attempts per outage before giving up for good (default 10) | ## Tool naming @@ -62,7 +66,9 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` - Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server. - Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. - Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders. -- On disconnect/crash: no auto-reconnect. Registered tools remain until plugin disposal or a successful re-sync, and calls can fail against the closed transport; reload with HMR or restart the Host to reconnect. +- On disconnect/crash: the supervisor restarts the original server config with exponential backoff (`reconnect.initialDelayMs` doubling up to `reconnect.maxDelayMs`) and re-runs discovery on success — the recovered generation replaces the previous one, so tools neither duplicate nor leak. During the outage the last good generation stays registered; calls against it fail until recovery. +- Reconnection is budgeted per outage: after `reconnect.maxAttempts` consecutive failures the server's tools are unregistered and reconnection stops until an HMR reload or Host restart. A connection that survives past `maxDelayMs` resets the budget, so an occasionally-crashing server recovers indefinitely while a crash-looping one — even with briefly successful connects — still exhausts the cap instead of restarting forever. +- Reconnect states are user-visible in logs: reconnecting (warn, with attempt count and delay), recovered (info), final failure and disabled-loss (error). Disposal cancels any pending reconnect. With `reconnect.enabled: false`, a lost connection keeps tools registered but failing until a reload — the manual-recovery behavior. ## Services consumed @@ -76,7 +82,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` #### What the model sees -After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp____` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync replaces the generation; plugin disposal removes it. +After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp____` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync — including the one after an automatic reconnect — replaces the generation; plugin disposal or an exhausted reconnect budget removes it. #### Token effect @@ -84,7 +90,7 @@ Data-dependent schema cost is paid on every request while the tools are register #### KV Cache effect -Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync that adds, removes, renames, or changes a tool replaces definitions and may invalidate reuse from the first changed schema token. +Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync that adds, removes, renames, or changes a tool replaces definitions and may invalidate reuse from the first changed schema token; a reconnect that recovers an unchanged list reproduces identical definitions and stays prefix-stable. ### Tool-call history and results @@ -104,6 +110,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred. - **Startup timeout is inherited from the MCP SDK** — DSH does not yet expose a connection/discovery timeout. Each initialize or paginated `tools/list` request uses the SDK's 60-second default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles. -- **Crash recovery is manual** — transport closure does not auto-reconnect; registered tools can remain visible but fail against the closed transport until an HMR reload or Host restart. +- **Reconnect triggers on transport close** — a crashed stdio child fires it; Streamable HTTP failures surface per request and through the SDK transport's own SSE-stream recovery, so an unreachable HTTP server is retried per call rather than respawned by the supervisor. - **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred. - **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index b2da1119af..1b5b5c523e 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -45,6 +45,10 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc | `headers` | http | 否 | 额外标头(例如认证 token) | | `toolCallTimeoutMs` | 两者 | 否 | 每次 `callTool` 调用的超时(默认 60000) | | `failOnStartupError` | 两者 | 否 | 初始连接或工具同步失败时拒绝插件激活(默认 `false`) | +| `reconnect.enabled` | 两者 | 否 | 连接丢失后自动重新连接(默认 `true`) | +| `reconnect.initialDelayMs` | 两者 | 否 | 首次重连延迟(毫秒);每次连续失败尝试翻倍(默认 500) | +| `reconnect.maxDelayMs` | 两者 | 否 | 退避上限(毫秒);同时也是重置尝试预算所需的正常运行时长(默认 30000) | +| `reconnect.maxAttempts` | 两者 | 否 | 每次中断期间连续失败尝试次数上限,超出后彻底放弃(默认 10) | ## 工具命名 @@ -62,7 +66,9 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc - 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。 - 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。 - Native/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。 -- 断开/崩溃时:不自动重新连接。已注册工具会一直保留到对插件执行 dispose(资源释放)或成功重新同步,针对已关闭传输的调用可能失败;请通过 HMR 重新加载或重启 Host 来重新连接。 +- 断开/崩溃时:supervisor 以指数退避(`reconnect.initialDelayMs` 逐次翻倍,上限 `reconnect.maxDelayMs`)重启原始服务器配置,成功后重新执行发现——恢复的世代会替换前一个,因此工具既不会重复也不会泄漏。中断期间最后一个正常世代保持注册;针对它的调用在恢复前会失败。 +- 重连按中断预算控制:连续失败达到 `reconnect.maxAttempts` 次后,该服务器的工具会被注销,重连停止,直到 HMR 重载或重启 Host。连接存活超过 `maxDelayMs` 会重置预算,因此偶尔崩溃的服务器可以无限恢复,而崩溃循环的服务器——即使短暂连接成功——仍会耗尽上限而非永远重启。 +- 重连状态在日志中对用户可见:reconnecting(warn,含尝试次数和延迟)、recovered(info)、最终失败和 disabled-loss(error)。dispose(资源释放)会取消任何待执行的重连。设置 `reconnect.enabled: false` 时,连接丢失后工具保持注册但调用失败,直到重载——即手动恢复行为。 ## 消费的服务 @@ -76,7 +82,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc #### 模型看到的内容 -初始发现成功后,每个已声明的 MCP 工具都会显示为名为 `mcp____`(或其确定性规范化形式)的原生工具,并携带服务器提供的描述和输入 schema。成功的重新同步会替换整个世代;对插件执行 dispose 会移除该世代。 +初始发现成功后,每个已声明的 MCP 工具都会显示为名为 `mcp____`(或其确定性规范化形式)的原生工具,并携带服务器提供的描述和输入 schema。成功的重新同步——包括自动重连后的同步——会替换整个世代;对插件执行 dispose(资源释放)或重连预算耗尽会移除该世代。 #### Token 影响 @@ -84,7 +90,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc #### KV Cache 影响 -只要已发现工具集合及其 schema 不变,前缀就保持稳定。增加、移除、重命名或更改工具的重新同步会替换定义,并可能使从第一个变化的 schema token 起的复用失效。 +只要已发现工具集合及其 schema 不变,前缀就保持稳定。增加、移除、重命名或更改工具的重新同步会替换定义,并可能使从第一个变化的 schema token 起的复用失效;恢复了未变列表的重连会生成完全相同的定义,前缀保持稳定。 ### 工具调用历史与结果 @@ -104,6 +110,6 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc - **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。 - **启动超时继承自 MCP SDK**:DSH 尚未公开连接/发现超时。每次 initialize 请求或分页 `tools/list` 请求都使用 SDK 默认的 60 秒,因此在初始同步完成期间,无响应的 server 或 cursor chain 可能同时延迟激活与 teardown。 -- **崩溃恢复需要手动触发**:传输关闭后不会自动重新连接;已注册工具可能仍然可见,但会因传输已关闭而调用失败,直到 HMR 重载或重启 Host。 +- **重连在传输关闭时触发**:崩溃的 stdio 子进程会触发重连;Streamable HTTP 失败通过每次请求以及 SDK 传输自身的 SSE(Server-Sent Events)流恢复机制暴露,因此不可达的 HTTP 服务器会按调用重试,而非由 supervisor 重新 spawn。 - **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。 - **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。 diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 3f65271905..18f4cac7a8 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -40,6 +41,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts new file mode 100644 index 0000000000..47bf3c8003 --- /dev/null +++ b/packages/mcp/mcp-client/src/connection.ts @@ -0,0 +1,292 @@ +/** + * Connection supervisor: owns the MCP client/transport generations for one + * plugin instance, keeps the harness tool registry in sync with the live + * generation, and — when the connection drops — restarts the configured + * server with bounded exponential backoff. + * + * One outage shares one attempt budget (`maxAttempts` consecutive failed + * attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A + * connection that stays up past the stability window closes the outage, so + * the next disconnect starts a fresh budget while a crash-looping server — + * even one whose connects briefly succeed — still exhausts the cap instead of + * restarting forever. Exhaustion unregisters the server's tools and stops; + * disposal (including HMR) is the only way back from that state. + * + * @module + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' +import type { Context } from 'cordis' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { createTransport } from './transport.ts' +import { syncTools } from './tools.ts' +import type { ToolBridgeOptions, ToolDisposers } from './tools.ts' +import type { Config } from './index.ts' + +/** Automatic reconnect policy for one MCP server connection. */ +export interface ReconnectConfig { + /** Reconnect automatically after a lost connection (default true). */ + enabled?: boolean + /** First reconnect delay in milliseconds; doubles per consecutive failed attempt (default 500). */ + initialDelayMs?: number + /** Backoff ceiling in milliseconds; also the uptime after which the attempt budget resets (default 30000). */ + maxDelayMs?: number + /** Consecutive failed attempts per outage before giving up for good (default 10). */ + maxAttempts?: number +} + +/** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */ +export const RECONNECT_DEFAULTS: Required = Object.freeze({ + enabled: true, + initialDelayMs: 500, + maxDelayMs: 30_000, + maxAttempts: 10, +}) + +/** Fully resolved reconnect policy captured at plugin load. */ +export type ResolvedReconnectPolicy = Readonly> + +/** + * The one explicit resolve step from raw reconnect config to the policy the + * supervisor runs. Programmatic construction may bypass Schemastery + * normalization, so every default and bound is re-judged here — misconfiguration + * fails the plugin instance at load. + * + * @param config - Raw `reconnect` config; omission uses the defaults. + * @param path - Diagnostic prefix naming the config location in thrown messages. + * @returns The frozen resolved policy. + */ +export function resolveReconnectPolicy(config: ReconnectConfig | undefined, path: string): ResolvedReconnectPolicy { + if (config !== undefined) { + for (const key of Object.keys(config)) { + if (!Object.hasOwn(RECONNECT_DEFAULTS, key)) throw new Error(`${path}.${key} is not a reconnect option`) + } + } + const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled + const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs + const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs + const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts + /* jscpd:ignore-start — domain-specific delay validation parallels llm retry-policy; not extractable */ + if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (initialDelayMs > maxDelayMs) { + throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`) + } + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { + throw new Error(`${path}.maxAttempts must be a positive integer`) + } + /* jscpd:ignore-end */ + return Object.freeze({ enabled, initialDelayMs, maxDelayMs, maxAttempts }) +} + +/** Result from the initial connection attempt, for startup-await semantics. */ +export interface ConnectionOutcome { + /** If the initial connection or tool sync failed, the error; otherwise absent. */ + error?: unknown +} + +/** Handle for one plugin instance's supervised connection. */ +export interface ConnectionHandle { + /** + * Settles when the first connection attempt completes (success or failure). + * The supervisor enters its reconnect loop regardless; the caller decides + * whether a failed startup is fatal via `failOnStartupError`. + */ + ready: Promise + /** + * Stop reconnection, close the live client, wait for the in-flight attempt + * and queued tool syncs to quiesce, then unregister every tool this server + * still owns. + */ + dispose(): Promise +} + +/** + * Start the supervised connection for one MCP server and keep it alive per + * the reconnect policy. + * + * @param ctx - Cordis context providing the `tools` registry and logger. + * @param config - Resolved plugin config selecting the transport and server identity. + * @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}. + * @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown. + */ +export function startConnection(ctx: Context, config: Config, policy: ResolvedReconnectPolicy): ConnectionHandle { + const label = `mcp-client(${config.serverName})` + const opts: ToolBridgeOptions = { + registrationFailure: 'contain', + serverName: config.serverName, + toolCallTimeoutMs: config.toolCallTimeoutMs, + } + // The initial sync uses 'throw' when failOnStartupError is configured, so + // a registration conflict propagates to the startup-await path. Re-syncs + // and reconnect syncs always contain conflicts. + const startupOpts: ToolBridgeOptions = config.failOnStartupError + ? { ...opts, registrationFailure: 'throw' } + : opts + let isFirstSync = true + + let disposed = false + /** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */ + let client: Client | undefined + /** Live tool registrations owned by this server; only {@link enqueueSync} and dispose swap it. */ + let disposers: ToolDisposers = new Map() + let reconnectTimer: NodeJS.Timeout | undefined + /** Consecutive failed connection attempts within the current outage. */ + let failedAttempts = 0 + /** When the current generation finished connect + initial sync; undefined while down. */ + let connectedAt: number | undefined + /** The real error from the first connection attempt, for startup-await diagnostics. */ + let firstAttemptError: unknown + + /** A generation may act only while it is the current one on a live plugin. */ + const isCurrent = (generation: Client): boolean => !disposed && client === generation + + /** + * Serializes every syncTools call — initial syncs and notification re-syncs + * across all generations — so two syncs can never interleave their + * dispose-previous/register-next swap (which would double-dispose one + * generation and leak another). + */ + let syncChain: Promise = Promise.resolve() + function enqueueSync(generation: Client): Promise { + const syncOpts = isFirstSync ? startupOpts : opts + isFirstSync = false + const run = syncChain.then(async () => { + if (!isCurrent(generation)) return + disposers = await syncTools(generation, ctx, syncOpts, disposers) + }) + // The chain tail must survive a failed sync; the enqueuing caller owns reporting. + syncChain = run.catch(() => {}) + return run + } + + /** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */ + function generationDown(generation: Client): void { + if (!isCurrent(generation)) return + client = undefined + scheduleReconnect() + } + + function scheduleReconnect(): void { + if (!policy.enabled) { + const detail = connectedAt !== undefined + ? 'registered tools will fail until an HMR reload or Host restart' + : 'no tools were registered; reload the plugin or restart the Host to connect' + ctx.logger.error(`${label}: connection lost and reconnect is disabled — ${detail}`) + return + } + // A connection that stayed up past the stability window (= maxDelayMs, the + // longest backoff spacing) ended the previous outage: start a fresh budget. + if (connectedAt !== undefined && Date.now() - connectedAt >= policy.maxDelayMs) failedAttempts = 0 + connectedAt = undefined + failedAttempts += 1 + if (failedAttempts > policy.maxAttempts) { + // Enqueue the give-up disposal so it cannot race an in-flight sync's + // phase-2 swap (which checks isCurrent inside the queue). + syncChain = syncChain.then(() => { + for (const dispose of disposers.values()) dispose() + disposers = new Map() + }) + ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect`) + return + } + const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1)) + ctx.logger.warn(`${label}: connection lost; reconnecting in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`) + reconnectTimer = setTimeout(() => { + reconnectTimer = undefined + settling = connectGeneration() + }, delayMs) + // An armed reconnect timer must never hold the process open on its own. + reconnectTimer.unref() + } + + /** + * One connection attempt: fresh transport + client (the MCP SDK binds a + * Protocol to one transport for life), connect, then queue the initial tool + * sync. Every failure funnels through {@link generationDown}; success arms + * the onclose-driven disconnect path. Never rejects. + */ + async function connectGeneration(): Promise { + const generation = new Client( + { name: 'dsh-mcp-client', version: '0.0.1' }, + { capabilities: {} }, + ) + client = generation + generation.onclose = () => { generationDown(generation) } + // Registered before connect so a list change during the initial sync is + // queued behind it rather than dropped. + generation.setNotificationHandler( + ToolListChangedNotificationSchema, + async () => { + if (!isCurrent(generation)) return + ctx.logger.info(`${label}: tool list changed, re-syncing`) + try { + await enqueueSync(generation) + } catch (error) { + // Fetch-phase failure: the previous generation is still registered + // and `disposers` still owns it — keep serving the last good list. + if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`) + } + }, + ) + try { + await generation.connect(createTransport(config)) + await enqueueSync(generation) + } catch (error) { + if (firstAttemptError === undefined) firstAttemptError = error + // When the transport closed first, onclose already logged and scheduled. + if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`) + try { await generation.close() } catch { /* transport already gone */ } + generationDown(generation) + return + } + if (!isCurrent(generation)) return + connectedAt = Date.now() + if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`) + } + + /** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */ + let settling = connectGeneration() + + // The ready promise settles when the first attempt finishes (regardless of + // success). If the first attempt fails and reconnect is enabled, the + // supervisor is already scheduling a retry — ready just reports the outcome. + const ready: Promise = settling.then(() => { + // After settling: if client is set the initial connect+sync succeeded. + // If not, the supervisor either scheduled a retry (error logged) or gave + // up (error logged). Either way the outcome is reported with the real error. + // Note: settling.then() is a microtask; stdio onclose is a macrotask — so + // a server that crashes AFTER a successful initial sync cannot flip client + // to undefined before this continuation runs. + if (client !== undefined) return {} + /* v8 ignore next -- defensive: firstAttemptError is always set when connect/sync fails */ + return { error: firstAttemptError ?? new Error(`${label}: initial connection failed`) } + }) + + return { + ready, + async dispose(): Promise { + disposed = true + if (reconnectTimer !== undefined) { + clearTimeout(reconnectTimer) + reconnectTimer = undefined + } + const current = client + client = undefined + if (current !== undefined) { + try { await current.close() } catch { /* transport already gone */ } + } + // Quiesce, don't just request it: the in-flight attempt enqueues its + // sync before settling, so awaiting both leaves `disposers` final. + await settling + await syncChain + for (const dispose of disposers.values()) dispose() + disposers = new Map() + }, + } +} diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 0fe7dd9de3..6696b7ce15 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -15,14 +15,14 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' -import { createTransport } from './transport.ts' -import { syncTools } from './tools.ts' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { RECONNECT_DEFAULTS, resolveReconnectPolicy, startConnection } from './connection.ts' +import type { ReconnectConfig } from './connection.ts' // Side-effect type import: declaration-merges `ctx.tools` onto Context. import type {} from '@deepseek-ai/dsh-tools' export type { McpResult } from './tools.ts' +export type { ReconnectConfig, ResolvedReconnectPolicy } from './connection.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'mcp-client' @@ -74,6 +74,8 @@ export interface StdioConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ @@ -94,11 +96,20 @@ export interface StreamableHttpConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig } /** Configuration for one stdio or Streamable HTTP MCP server. */ export type Config = StdioConfig | StreamableHttpConfig +const Reconnect: z = z.object({ + enabled: z.boolean().default(RECONNECT_DEFAULTS.enabled), + initialDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.initialDelayMs), + maxDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.maxDelayMs), + maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(RECONNECT_DEFAULTS.maxAttempts), +}) + export const Config = z.union([ z.object({ transport: z.const('stdio'), @@ -109,6 +120,7 @@ export const Config = z.union([ cwd: z.string().default(''), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), failOnStartupError: z.boolean().default(false), + reconnect: Reconnect, }), z.object({ transport: z.const('streamable-http'), @@ -117,6 +129,7 @@ export const Config = z.union([ headers: z.dict(String).default({}), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), failOnStartupError: z.boolean().default(false), + reconnect: Reconnect, }), ]) as unknown as z @@ -131,7 +144,12 @@ export const Config = z.union([ * @returns startup readiness after connection and initial tool discovery settle. */ export async function apply(ctx: Context, config: Config): Promise { - // Reserve the namespace first: a duplicate `serverName` fails THIS instance + // Fail loud at load: reconnect misconfiguration (including programmatic + // construction that bypassed Schemastery) rejects THIS instance before any + // effect registers. + const reconnect = resolveReconnectPolicy(config.reconnect, `mcp-client(${config.serverName}): reconnect`) + + // Reserve the namespace next: a duplicate `serverName` fails THIS instance // at load with an actionable error and leaves the earlier instance intact. ctx.effect(() => { let names = activeServerNames.get(ctx.root) @@ -148,58 +166,22 @@ export async function apply(ctx: Context, config: Config): Promise { return () => void names.delete(config.serverName) }, 'mcp-client.serverName') - const transport = createTransport(config) - const client = new Client( - { name: 'dsh-mcp-client', version: '0.0.1' }, - { capabilities: {} }, - ) + // The supervisor owns the client/transport generations, the reconnect + // loop, and the live tool registrations; disposal stops reconnection, + // quiesces in-flight work, and unregisters the current generation. + const connection = startConnection(ctx, config, reconnect) - const opts = { - registrationFailure: 'contain' as const, - serverName: config.serverName, - toolCallTimeoutMs: config.toolCallTimeoutMs, - } - - // Connect and set up tools. `ready` always settles to an outcome so rollback - // can close a partially opened client even when strict startup later rejects. - // Its accessor returns the CURRENT disposer generation, so disposal always - // unregisters the live set, not the first one. - const ready = (async () => { - await client.connect(transport) - - let disposers = await syncTools(client, ctx, { - ...opts, - registrationFailure: config.failOnStartupError ? 'throw' : 'contain', - }, new Map()) - - client.setNotificationHandler( - ToolListChangedNotificationSchema, - async () => { - ctx.logger.info(`mcp-client(${config.serverName}): tool list changed, re-syncing`) - try { - disposers = await syncTools(client, ctx, opts, disposers) - } catch (error) { - // Fetch-phase failure: the previous generation is still registered - // and `disposers` still owns it — keep serving the last good list. - ctx.logger.error(`mcp-client(${config.serverName}): tool re-sync failed: ${String(error)}`) - } - }, - ) - - return { getDisposers: () => disposers } - })().catch((error: unknown) => { - ctx.logger.error(`mcp-client(${config.serverName}): startup failed: ${String(error)}`) - return { getDisposers: () => new Map void>(), error } - }) - - ctx.effect(() => async () => { - const outcome = await ready - for (const dispose of outcome.getDisposers().values()) dispose() - try { await client.close() } catch { /* transport already gone */ } + ctx.effect(() => { + return () => connection.dispose() }, 'mcp-client.connection') - const outcome = await ready - if ('error' in outcome && config.failOnStartupError) { + // Block plugin activation on the initial connection + tool discovery so + // Cordis consumers observe the tools immediately after the fiber activates. + // When failOnStartupError is true, a failed initial attempt rejects the + // fiber (Cordis rolls it back); otherwise the error is logged and the + // supervisor enters its reconnect loop. + const outcome = await connection.ready + if (outcome.error !== undefined && config.failOnStartupError) { throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error }) } } diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index e30a1ee716..f2a9982d59 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -124,6 +124,33 @@ describe('mcp-client plugin module exports', () => { } as never) expect(resolved.serverName).toBe('github-prod_1') }) + + it('Config schema materializes reconnect defaults and merges partial overrides', () => { + const omitted = ConfigSchema({ + transport: 'stdio', + serverName: 'srv', + command: 'echo', + } as never) + expect(omitted.reconnect).toEqual({ enabled: true, initialDelayMs: 500, maxDelayMs: 30_000, maxAttempts: 10 }) + + const partial = ConfigSchema({ + transport: 'stdio', + serverName: 'srv', + command: 'echo', + reconnect: { initialDelayMs: 100 }, + } as never) + expect(partial.reconnect).toEqual({ enabled: true, initialDelayMs: 100, maxDelayMs: 30_000, maxAttempts: 10 }) + }) + + it('Config schema rejects an invalid reconnect block', () => { + // schemastery unions wrap branch errors, so assert the throw only. + expect(() => ConfigSchema({ + transport: 'stdio', + serverName: 'srv', + command: 'echo', + reconnect: { maxAttempts: 0 }, + } as never)).toThrow() + }) }) describe('apply (plugin lifecycle)', () => { @@ -216,8 +243,8 @@ describe('apply (plugin lifecycle)', () => { expect(mockListTools).not.toHaveBeenCalled() expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() - // Disposal exercises the empty fallback accessor: nothing to unregister, - // close still attempted, no throw. + // Disposal cancels the scheduled reconnect attempt: nothing to + // unregister, close already attempted by the failed attempt, no throw. await ctx.fiber.dispose() await sleep(50) expect(mockClose).toHaveBeenCalled() diff --git a/packages/mcp/mcp-client/tests/fixture-server.ts b/packages/mcp/mcp-client/tests/fixture-server.ts index 8491b96634..974e2a26b0 100644 --- a/packages/mcp/mcp-client/tests/fixture-server.ts +++ b/packages/mcp/mcp-client/tests/fixture-server.ts @@ -51,6 +51,17 @@ server.registerTool('image', { ], })) +server.registerTool('crash', { + title: 'Crash Tool', + description: 'Replies, then exits the server process (crash-recovery test).', + inputSchema: {}, +}, async () => { + // Exit AFTER the response flushes so the caller observes a clean result + // followed by a transport close, like a real post-reply crash. + setTimeout(() => process.exit(7), 25) + return { content: [{ type: 'text', text: 'crashing' }] } +}) + // Dotted name: legal in MCP, illegal in the DeepSeek function-name contract. // Exercises the bridge's normalize-and-hash public-name path end to end. server.registerTool('admin.reset', { diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index e1f51d20e9..7f09b7afae 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -13,7 +13,7 @@ import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' @@ -200,6 +200,87 @@ describe('fixture server — disposal', () => { }, 30_000) }) +describe('fixture server — crash recovery', () => { + function crashConfig(serverName: string, reconnect: NonNullable): Config { + return { + transport: 'stdio', + serverName, + command: process.execPath, + args: [fixtureServerPath], + env: {}, + cwd: packageDir, + toolCallTimeoutMs: 15_000, + failOnStartupError: false, + reconnect, + } + } + + it('auto-reconnects after a stdio crash and serves tool calls again', async () => { + const ctx = await mountRegistry() + await apply(ctx, crashConfig('crashy', { initialDelayMs: 50, maxDelayMs: 500, maxAttempts: 40 })) + + const before = await ctx.tools.execute({ + signal: testToolSignal, + callId: nextCallId(), name: 'mcp__crashy__add', arguments: { a: 2, b: 3 }, + }) + expect(textOf(before.content[0])).toBe('5') + + // The crash tool replies, then kills the real child process. + const crash = await ctx.tools.execute({ + signal: testToolSignal, + callId: nextCallId(), name: 'mcp__crashy__crash', arguments: {}, + }) + expect(crash.isError).toBe(false) + + // Recovery is proven by the world: a post-crash call round-trips through + // the respawned server process. + await vi.waitFor(async () => { + const after = await ctx.tools.execute({ + signal: testToolSignal, + callId: nextCallId(), name: 'mcp__crashy__add', arguments: { a: 20, b: 22 }, + }) + expect(after.isError).toBe(false) + expect(textOf(after.content[0])).toBe('42') + }, { timeout: 15_000, interval: 250 }) + + // The recovered generation replaced the dead one: no duplicates, no leak. + const addEntries = ctx.tools.schemas().map(s => s.name).filter(name => name === 'mcp__crashy__add') + expect(addEntries).toHaveLength(1) + + await ctx.fiber.dispose() + await sleep(200) + }, 30_000) + + it('plugin unload during an outage stops reconnection and unregisters tools', async () => { + const ctx = await mountRegistry() + const fiber = ctx.plugin( + { name: 'mcp-client', inject: ['tools'], apply }, + crashConfig('ephemeral', { initialDelayMs: 8_000, maxDelayMs: 8_000, maxAttempts: 5 }), + ) + // Cordis awaits async apply() as startup work; wait for it. + await vi.waitFor(() => { expect(ctx.tools.get('mcp__ephemeral__add')).toBeDefined() }, { timeout: 20_000 }) + + const crash = await ctx.tools.execute({ + signal: testToolSignal, + callId: nextCallId(), name: 'mcp__ephemeral__crash', arguments: {}, + }) + expect(crash.isError).toBe(false) + + // Give the transport close a moment to land the supervisor in its 8s + // backoff wait, then unload: disposal must not sit out the backoff. + await sleep(300) + const started = Date.now() + await fiber.dispose() + expect(Date.now() - started).toBeLessThan(4_000) + + expect(ctx.tools.get('mcp__ephemeral__add')).toBeUndefined() + await sleep(200) + expect(ctx.tools.get('mcp__ephemeral__add')).toBeUndefined() + + await ctx.fiber.dispose() + }, 30_000) +}) + // ---- @modelcontextprotocol/server-everything ---- describe('server-everything — official test server', () => { diff --git a/packages/mcp/mcp-client/tests/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts new file mode 100644 index 0000000000..f52dfdb65b --- /dev/null +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -0,0 +1,413 @@ +/** + * Tests for the mcp-client connection supervisor: crash-driven reconnection + * with bounded backoff, generation-safe tool re-registration, the failure + * cap, the stability-window budget reset, and disposal stopping reconnection. + * Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites. + */ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Context } from 'cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { Config } from '@deepseek-ai/dsh-mcp-client' + +// ---- Mock MCP SDK ---- + +// vi.mock factories are hoisted above every import/const, so the mock fns and +// class must be created inside vi.hoisted to exist when the factories run. +const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances } = vi.hoisted(() => { + const mockConnect = vi.fn<() => Promise>() + const mockClose = vi.fn<() => Promise>() + const mockListTools = vi.fn<(_params?: Record) => Promise>() + const mockCallTool = vi.fn<( + _params?: Record, _compatibilitySchema?: unknown, _options?: unknown, + ) => Promise>() + const mockSetNotificationHandler = vi.fn() + const mockRequest = vi.fn(async ( + request: { method: string; params?: Record }, + _schema: unknown, + options?: unknown, + ): Promise => { + if (request.method === 'tools/list') return await mockListTools(request.params) + if (request.method === 'tools/call') return await mockCallTool(request.params, undefined, options) + throw new Error(`unexpected MCP request: ${request.method}`) + }) + class MockClient { + onclose: (() => void) | undefined + connect = mockConnect + close = mockClose + request = mockRequest + setNotificationHandler = mockSetNotificationHandler + constructor() { instances.push(this) } + } + const instances: MockClient[] = [] + return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances } +}) + +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: MockClient, +})) + +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ + StdioClientTransport: vi.fn(), +})) + +vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: vi.fn(), +})) + +// vi.mock is hoisted above static imports, so the modules under test see the +// mocked SDK even through a static import. +import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' +import { RECONNECT_DEFAULTS, resolveReconnectPolicy } from '@deepseek-ai/dsh-mcp-client/src/connection.ts' + +// ---- Helpers ---- + +const testToolSignal = new AbortController().signal + +async function mountRegistry(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +function sleep(ms: number): Promise { + // Annotated binding (not withResolvers()): the tests lint layer runs + // no-invalid-void-type with default options, which rejects the explicit + // type argument in call position but accepts the inferred form. + const gate: PromiseWithResolvers = Promise.withResolvers() + setTimeout(gate.resolve, ms) + return gate.promise +} + +/** Capture the supervisor's logger lines by level on one context. */ +function captureLogs(ctx: Context): { warns: string[]; errors: string[]; infos: string[] } { + const warns: string[] = [] + const errors: string[] = [] + const infos: string[] = [] + ctx.logger.warn = ((message: unknown) => { warns.push(String(message)) }) as typeof ctx.logger.warn + ctx.logger.error = ((message: unknown) => { errors.push(String(message)) }) as typeof ctx.logger.error + ctx.logger.info = ((message: unknown) => { infos.push(String(message)) }) as typeof ctx.logger.info + return { warns, errors, infos } +} + +function stdioConfig(reconnect?: Config['reconnect']): Config { + return { + transport: 'stdio', + serverName: 'srv', + command: 'echo', + args: [], + env: {}, + cwd: '', + toolCallTimeoutMs: 60_000, + failOnStartupError: false, + ...reconnect === undefined ? {} : { reconnect }, + } +} + +/** The tool list the mock server advertises after a successful (re)connect. */ +function listing(...names: string[]): { tools: { name: string; inputSchema: { type: string } }[]; nextCursor: undefined } { + return { + tools: names.map(name => ({ name, inputSchema: { type: 'object' } })), + nextCursor: undefined, + } +} + +let callSeq = 0 +function nextCallId(): CallId { + return CallId(`reconnect-${++callSeq}`) +} + +// ---- Tests ---- + +describe('reconnect supervisor', () => { + let ctx: Context + + beforeEach(async () => { + vi.clearAllMocks() + instances.length = 0 + mockConnect.mockResolvedValue(undefined) + mockClose.mockResolvedValue(undefined) + mockListTools.mockResolvedValue(listing('remote')) + mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }) + ctx = await mountRegistry() + }) + + it('reconnects after a transport close, re-syncs tools through the new generation, and serves calls', async () => { + const { warns, infos } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 5, maxDelayMs: 40, maxAttempts: 5 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + expect(instances).toHaveLength(1) + + // The recovered server advertises a different list: the swap must neither + // duplicate nor leak the pre-crash generation. + mockListTools.mockResolvedValue(listing('revived')) + instances[0]!.onclose?.() + + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__revived')).toBeDefined() }) + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(instances).toHaveLength(2) + expect(mockConnect).toHaveBeenCalledTimes(2) + + // Post-recovery calls execute through the re-registered definition. + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: nextCallId(), name: 'mcp__srv__revived', arguments: {}, + }) + expect(result.isError).toBe(false) + + // User-visible state: reconnecting and recovered are distinct lines. + expect(warns.some(line => line.includes('reconnecting in 5ms (attempt 1/5)'))).toBe(true) + expect(infos.some(line => line.includes('reconnected and re-synced tools'))).toBe(true) + + // A late close signal from the replaced generation is ignored. + instances[0]!.onclose?.() + await sleep(30) + expect(instances).toHaveLength(2) + }) + + it('stops at the failure cap, unregisters the tools, and reports final failure', async () => { + const { warns, errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + mockConnect.mockRejectedValue(new Error('server gone')) + // A failing close on the failed attempt's cleanup must not break the loop. + mockClose.mockRejectedValue(new Error('already closed')) + instances[0]!.onclose?.() + + await vi.waitFor(() => { + expect(errors.some(line => line.includes('giving up after 2 consecutive failed reconnect attempts'))).toBe(true) + }) + // Stale tools do not leak past final failure. + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + // Initial connect + exactly maxAttempts reconnect attempts. + expect(mockConnect).toHaveBeenCalledTimes(3) + expect(warns.some(line => line.includes('connection attempt failed: Error: server gone'))).toBe(true) + await sleep(30) + expect(mockConnect).toHaveBeenCalledTimes(3) + }) + + it('dispose during the backoff wait cancels the pending reconnect', async () => { + await apply(ctx, stdioConfig({ initialDelayMs: 60_000, maxDelayMs: 60_000, maxAttempts: 5 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + instances[0]!.onclose?.() + // Now waiting out a 60s backoff; disposal must return promptly anyway. + await ctx.fiber.dispose() + await sleep(30) + expect(mockConnect).toHaveBeenCalledTimes(1) + expect(instances).toHaveLength(1) + }) + + it('a transport close after dispose schedules nothing', async () => { + const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig()) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + await fiber.dispose() + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + + // The disposer's client.close() fires onclose in the real SDK. + instances[0]!.onclose?.() + await sleep(30) + expect(instances).toHaveLength(1) + expect(mockConnect).toHaveBeenCalledTimes(1) + }) + + it('reconnect disabled keeps the registered tools and reports manual recovery', async () => { + const { errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ enabled: false })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + instances[0]!.onclose?.() + await sleep(30) + expect(mockConnect).toHaveBeenCalledTimes(1) + // Pre-reconnect contract: the generation stays registered until disposal. + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + expect(errors.some(line => line.includes('reconnect is disabled'))).toBe(true) + }) + it('reconnect disabled after a failed initial connect reports no registered tools', async () => { + const { errors } = captureLogs(ctx) + mockConnect.mockRejectedValue(new Error('refused')) + await apply(ctx, stdioConfig({ enabled: false })) + await sleep(30) + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(errors.some(line => line.includes('no tools were registered'))).toBe(true) + }) + + it('an uptime past the stability window resets the attempt budget', async () => { + const { errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 30, maxAttempts: 1 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + instances[0]!.onclose?.() + await vi.waitFor(() => { expect(instances).toHaveLength(2) }) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + // Outlive the stability window (= maxDelayMs), then crash again: the + // budget restarts at attempt 1 instead of exceeding maxAttempts. + await sleep(40) + instances[1]!.onclose?.() + await vi.waitFor(() => { expect(instances).toHaveLength(3) }) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + expect(errors).toHaveLength(0) + }) + + it('a crash loop with briefly successful connects still exhausts the cap', async () => { + const { errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 10_000, maxAttempts: 1 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + // Crash, recover (attempt 1 of 1), crash again well inside the stability + // window: the successful connect must not launder the budget. + instances[0]!.onclose?.() + await vi.waitFor(() => { expect(instances).toHaveLength(2) }) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + instances[1]!.onclose?.() + + await vi.waitFor(() => { + expect(errors.some(line => line.includes('giving up after 1 consecutive failed reconnect attempts'))).toBe(true) + }) + expect(instances).toHaveLength(2) + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + }) + + it('a connect rejection racing its own transport close schedules exactly one retry per attempt', async () => { + const { errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 3 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + // Each reconnect attempt sees the stdio transport die (onclose) AND its + // connect() reject — the real SDK emits both for a spawn failure. + mockConnect.mockImplementation(async () => { + instances.at(-1)!.onclose?.() + throw new Error('spawn failed') + }) + instances[0]!.onclose?.() + + await vi.waitFor(() => { + expect(errors.some(line => line.includes('giving up after 3 consecutive failed reconnect attempts'))).toBe(true) + }) + // Initial generation + exactly one generation per budgeted attempt: a + // double-scheduled retry would create more. + expect(instances).toHaveLength(4) + expect(errors.filter(line => line.includes('giving up')).length).toBe(1) + }) + + it('a transport that closes during a resolving connect registers nothing from the dead generation', async () => { + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + expect(mockListTools).toHaveBeenCalledTimes(1) + + mockConnect.mockImplementation(async () => { + instances.at(-1)!.onclose?.() + }) + instances[0]!.onclose?.() + + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() }) + // The dead generations never reached tool discovery. + expect(mockListTools).toHaveBeenCalledTimes(1) + }) + + it('dispose during an in-flight initial sync quiesces without leaking tools', async () => { + const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 5 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + // Block the reconnect attempt's tool discovery until after dispose starts. + const gate: PromiseWithResolvers = Promise.withResolvers() + mockListTools.mockImplementation(() => gate.promise) + instances[0]!.onclose?.() + await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) }) + + const disposing = fiber.dispose() + await sleep(10) + gate.resolve(listing('late')) + await disposing + + // The late sync's swap ran, then disposal unregistered its result: no + // generation survives the plugin. + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__late')).toBeUndefined() + }) + + it('a re-sync failing because dispose closed the transport stays silent', async () => { + const { errors } = captureLogs(ctx) + const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig()) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + const gate: PromiseWithResolvers = Promise.withResolvers() + mockListTools.mockImplementation(() => gate.promise) + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + const resync = handler() + await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) }) + + const disposing = fiber.dispose() + await sleep(10) + gate.reject(new Error('Connection closed')) + await disposing + await resync + + expect(errors.some(line => line.includes('tool re-sync failed'))).toBe(false) + }) + + it('a stale notification handler from a replaced generation is ignored', async () => { + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 5 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + instances[0]!.onclose?.() + await vi.waitFor(() => { expect(instances).toHaveLength(2) }) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + const listCalls = mockListTools.mock.calls.length + + const staleHandler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + await staleHandler() + expect(mockListTools).toHaveBeenCalledTimes(listCalls) + }) +}) + +// ---- Policy resolution ---- + +describe('resolveReconnectPolicy', () => { + const path = 'mcp-client(srv): reconnect' + + it('resolves omission to the defaults, frozen', () => { + const policy = resolveReconnectPolicy(undefined, path) + expect(policy).toEqual(RECONNECT_DEFAULTS) + expect(Object.isFrozen(policy)).toBe(true) + }) + + it('keeps explicit values', () => { + expect(resolveReconnectPolicy( + { enabled: false, initialDelayMs: 1, maxDelayMs: 2, maxAttempts: 7 }, + path, + )).toEqual({ enabled: false, initialDelayMs: 1, maxDelayMs: 2, maxAttempts: 7 }) + }) + + it('rejects unknown keys', () => { + expect(() => resolveReconnectPolicy({ jitterRatio: 0.5 } as never, path)) + .toThrow(/reconnect\.jitterRatio is not a reconnect option/) + }) + + it('rejects out-of-range delays', () => { + expect(() => resolveReconnectPolicy({ initialDelayMs: 0 }, path)).toThrow(/initialDelayMs must be a positive finite number/) + expect(() => resolveReconnectPolicy({ initialDelayMs: Number.POSITIVE_INFINITY }, path)).toThrow(/initialDelayMs/) + expect(() => resolveReconnectPolicy({ maxDelayMs: -1 }, path)).toThrow(/maxDelayMs must be a positive finite number/) + }) + + it('rejects an initial delay above the ceiling', () => { + expect(() => resolveReconnectPolicy({ initialDelayMs: 100, maxDelayMs: 5 }, path)) + .toThrow(/initialDelayMs must be less than or equal to maxDelayMs/) + }) + + it('rejects non-positive-integer attempt caps', () => { + expect(() => resolveReconnectPolicy({ maxAttempts: 0 }, path)).toThrow(/maxAttempts must be a positive integer/) + expect(() => resolveReconnectPolicy({ maxAttempts: 1.5 }, path)).toThrow(/maxAttempts must be a positive integer/) + }) + + it('apply fails loud at load on a misconfigured reconnect', async () => { + const ctx = await mountRegistry() + await expect(apply(ctx, stdioConfig({ initialDelayMs: 100, maxDelayMs: 5 }))) + .rejects.toThrow(/initialDelayMs must be less than or equal to maxDelayMs/) + }) +}) diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index 461b250297..f42f4d4c2c 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -26,6 +26,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../util/timeout" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4912b264d7..abf6a6695b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4901,6 +4901,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools From 9fcbeb86159a3f6a4bfb4d917a1110aa7163f102 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 20:21:30 +0800 Subject: [PATCH 16/77] test(web): expect read_image in shipped composition --- apps/web/tests/shipped-composition.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index faf01ede62..84938828a7 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -36,6 +36,7 @@ const EXPECTED_TOOLS = [ 'list_agents', 'ralph', 'read', + 'read_image', 'send_message', 'skill', 'str_replace_editor', From 0a6561fa765dbf76dcd6ae8007e87c4cfdedb0b8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 20:36:24 +0800 Subject: [PATCH 17/77] refactor(fs): share read preflight helpers --- packages/e2b/fs-e2b/src/index.ts | 44 ++++++++++++-------------- packages/fs/tool-fs/src/read-image.ts | 11 ++----- packages/fs/tool-fs/src/read-target.ts | 34 ++++++++++++++++++++ packages/fs/tool-fs/src/read.ts | 12 ++----- 4 files changed, 58 insertions(+), 43 deletions(-) create mode 100644 packages/fs/tool-fs/src/read-target.ts diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index eda6866116..f78a382495 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -90,6 +90,24 @@ function commandOpts(signal: AbortSignal | undefined): { envs: Record> { + try { + // The pinned SDK's stream overload lies for empty files: content-length 0 + // returns '' instead of a ReadableStream. + const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as + ReadableStream | string + return typeof read === 'string' + ? new ReadableStream({ start(controller) { controller.close() } }) + : read + } catch (error: unknown) { + throw mapError(error, 'read', target.displayPath, signal) + } +} + function entryType(entry: EntryInfo): FsInfo['type'] { switch (entry.type) { case FileType.FILE: @@ -233,18 +251,7 @@ export class E2BFileSystem extends FileSystem { if (info.size !== undefined && info.size > maxBytes) { throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') } - let stream: ReadableStream - try { - // Same pinned-SDK quirk as streamText: content-length 0 returns '' - // instead of a ReadableStream. - const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as - ReadableStream | string - stream = typeof read === 'string' - ? new ReadableStream({ start(controller) { controller.close() } }) - : read - } catch (error: unknown) { - throw mapError(error, 'read', target.displayPath, signal) - } + const stream = await openReadStream(sandbox, target, signal) const reader = stream.getReader() const chunks: Uint8Array[] = [] let bytes = 0 @@ -288,18 +295,7 @@ export class E2BFileSystem extends FileSystem { override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { const sandbox = await this.ctx.e2b.getSandbox() await this.requireRegular(target, signal) - let stream: ReadableStream - try { - // The pinned SDK's stream overload lies for empty files: content-length 0 - // returns '' instead of a ReadableStream. - const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as - ReadableStream | string - stream = typeof read === 'string' - ? new ReadableStream({ start(controller) { controller.close() } }) - : read - } catch (error: unknown) { - throw mapError(error, 'read', target.displayPath, signal) - } + const stream = await openReadStream(sandbox, target, signal) const displayPath = target.displayPath return { async *[Symbol.asyncIterator](): AsyncGenerator { diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index f4b4d6e72f..61fe7325d7 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -19,9 +19,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' -import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' -import { sessionResolveOptions } from './session-cwd.ts' +import { resolveRegularReadTarget } from './read-target.ts' /** Extensions `read_image` accepts; magic-byte validation at the attachment service stays authoritative. */ const IMAGE_EXTENSIONS: Readonly> = { @@ -179,13 +178,7 @@ export function applyReadImageTool(ctx: Context): void { } await assertImageCapableRoute(ctx, exec, args.file_path) - const target = await ctx.fs.resolve(args.file_path, sessionResolveOptions(exec, args.file_path)) - const info = await ctx.fs.stat(target, exec.signal) - if (!info) { - ctx.emit('fs/observed', target, { kind: 'absent' }, exec) - throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') - } - if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + const { target, info } = await resolveRegularReadTarget(ctx, exec, args.file_path) // The tool result is one message carrying one image, so the per-message // aggregate bound applies beside the per-image bound. diff --git a/packages/fs/tool-fs/src/read-target.ts b/packages/fs/tool-fs/src/read-target.ts new file mode 100644 index 0000000000..02c83b35dc --- /dev/null +++ b/packages/fs/tool-fs/src/read-target.ts @@ -0,0 +1,34 @@ +/** + * Shared path resolution and regular-file validation for model-facing read tools. + * @module @deepseek-ai/dsh-tool-fs/src/read-target + */ + +import type { Context } from 'cordis' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { sessionResolveOptions } from './session-cwd.ts' + +/** + * Resolve a model-supplied path, observe absence, and require a regular file. + * @param ctx - the plugin context providing filesystem resolution and observation events. + * @param exec - the current tool execution, including session cwd and cancellation. + * @param requestedPath - the raw path supplied to the tool. + * @returns the resolved target and its single stat result. + */ +export async function resolveRegularReadTarget( + ctx: Context, + exec: ToolExecution, + requestedPath: string, +): Promise<{ target: FsTarget; info: FsInfo }> { + const target = await ctx.fs.resolve(requestedPath, sessionResolveOptions(exec, requestedPath)) + const info = await ctx.fs.stat(target, exec.signal) + if (info === undefined) { + ctx.emit('fs/observed', target, { kind: 'absent' }, exec) + throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + } + if (info.type !== 'file') { + throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + return { target, info } +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index dc4f07e8fe..acf07b85ef 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -7,11 +7,10 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools' -import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts' -import { sessionResolveOptions } from './session-cwd.ts' +import { resolveRegularReadTarget } from './read-target.ts' /** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */ export const READ_LIMIT = 2000 @@ -136,16 +135,9 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { isConcurrencySafe: () => true, async execute(args, exec) { const input = parseReadArgs(args, caps.limit) - const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath)) - // One stat: absence observation OR type check + size routing + present version. // A concurrent write can only make a later guarded mutation fail stale and require reread. - const info = await ctx.fs.stat(target, exec.signal) - if (!info) { - ctx.emit('fs/observed', target, { kind: 'absent' }, exec) - throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') - } - if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + const { target, info } = await resolveRegularReadTarget(ctx, exec, input.filePath) // Stream when the file is large OR size is unknown, so a size-less backend // never buffers an arbitrarily large file. From d9d2b11b9f1483f5d3af9f79edb2b81129ad3537 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:03:12 +0800 Subject: [PATCH 18/77] =?UTF-8?q?wip(web):=20agent=20preset=20UI=20flow=20?= =?UTF-8?q?=E2=80=94=20creator=20intro,=20custom=20group,=20subagent=20fla?= =?UTF-8?q?sh,=20chrome=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/AgentPresetLabel.tsx | 4 +- .../src/client/AgentPresetSeat.module.css | 53 ++++ .../src/client/AgentPresetSeat.tsx | 65 ++++- .../src/client/AgentPresetSection.module.css | 12 +- .../src/client/AgentPresetSection.tsx | 237 +++++++++--------- .../ui-agent-preset/src/client/index.ts | 5 +- .../ui-agent-preset/src/client/seat-store.ts | 20 +- .../ui-agent-preset/tests/components.spec.tsx | 7 +- .../src/client/skeleton/PermissionSelect.tsx | 8 +- .../client/ui-primitives/src/icons/index.tsx | 29 +++ .../src/client/SettingsRoot.module.css | 6 +- .../ui-settings/src/client/SettingsRoot.tsx | 4 +- .../src/client/SidebarRoot.module.css | 8 +- .../src/client/SubagentCatalogAction.tsx | 8 +- .../tests/conversation-ui.spec.tsx | 19 +- .../src/client/WorkspaceBrowser.module.css | 5 +- 16 files changed, 330 insertions(+), 160 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 517a856e9a..fb4b56490c 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -11,7 +11,7 @@ import { useEffect } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSettingsState } from './settings-store.ts' @@ -57,7 +57,7 @@ export function AgentPresetLabel({ const text = option === undefined ? undefined : presetDisplayText(option, t) return ( - + {text?.name ?? preset} ) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index a4e4c50309..93d9f2b6fe 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -36,6 +36,59 @@ color: var(--dsw-alias-label-primary); } +/* Introduce cue: the icon eases in on an overshoot-free expo curve, then the + name's characters fade up on a stagger (delays set inline per character). + All chars occupy their width from the start, so nothing reflows mid-run. */ +.introIcon { + animation: seat-icon-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes seat-icon-in { + from { + opacity: 0; + transform: scale(0.5); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +/* Wraps the staggered characters into one flex item, so the chip's gap + applies around the name as a whole rather than between characters. */ +.introText { + display: inline-block; + white-space: pre; +} + +.introChar { + display: inline-block; + white-space: pre; + opacity: 0; + animation: seat-char-in 0.4s ease-out forwards; +} + +@keyframes seat-char-in { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .introIcon, + .introChar { + animation: none; + opacity: 1; + } +} + .chevron { flex: none; color: var(--dsw-alias-label-caption); diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index f4357870bb..84734dccfc 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -15,7 +15,7 @@ import { useEffect, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSeatState } from './seat-store.ts' @@ -32,8 +32,17 @@ export interface AgentPresetSeatInjected { load: () => Promise /** Stage one preset for the next session. */ select: (id: string) => Promise + /** Clear the one-shot introduce cue once the chip has played it. */ + introduced: () => void } +/* Introduce timeline: the icon eases in first; the name's characters start + fading up once the icon has mostly landed, one every stagger tick, each + taking the fade duration to settle. The cue clears after the last one. */ +const INTRO_TEXT_DELAY_MS = 300 +const INTRO_CHAR_STAGGER_MS = 60 +const INTRO_CHAR_FADE_MS = 400 + /** Full component props. */ export type AgentPresetSeatProps = PropsRuntime<'conversation.hero.agentPreset'> @@ -45,7 +54,7 @@ export type AgentPresetSeatProps = * @param props - composed slot props. * @returns the chip, or null when the deployment composes no presets. */ -export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) { +export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) { const state = useAgentPresetSeat(snapshot => snapshot) const [open, setOpen] = useState(false) @@ -53,12 +62,52 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr void load() }, [load]) - // Nothing to choose between: the deployment composes no presets and every - // session shares the host composition. - if (state.options.length === 0 || state.current === '') return null - const chosen = state.options.find(option => option.id === state.current) const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) + const label = chosenText?.name ?? state.current + const ready = state.options.length > 0 && state.current !== '' + + // The introduce cue: the pick was staged from another screen (the settings + // creator entry), so the chip announces it — the icon eases in and each + // character of the name fades up on a stagger (CSS owns the motion; this + // effect only arms it and acknowledges the cue once the run is over). + const [introducing, setIntroducing] = useState(false) + useEffect(() => { + if (!state.introduce || !ready) return + const characters = Array.from(label) + if (characters.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + introduced() + return + } + setIntroducing(true) + const done = window.setTimeout(() => { + setIntroducing(false) + introduced() + }, INTRO_TEXT_DELAY_MS + characters.length * INTRO_CHAR_STAGGER_MS + INTRO_CHAR_FADE_MS) + return () => { window.clearTimeout(done) } + }, [state.introduce, ready, label, introduced]) + + // Nothing to choose between: the deployment composes no presets and every + // session shares the host composition. + if (!ready) return null + + // One wrapper span: the chip is a flex row with a gap, so loose character + // spans would each pick up the gap between them. + const shownLabel = introducing + ? ( + + {Array.from(label).map((character, index) => ( + + {character} + + ))} + + ) + : label return ( { setOpen(value => !value) }} > - - {chosenText?.name ?? state.current} + + {shownLabel} )} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index f29bf7cdf5..79dca43f7b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -363,6 +363,7 @@ create button vacated. Dashed like the Models page's add affordances: it reads as a place a preset will appear, not a command. */ .creatorButton { + box-sizing: border-box; align-self: stretch; display: flex; align-items: center; @@ -372,17 +373,18 @@ border: 1px dashed var(--dsw-alias-border-l3); border-radius: 12px; font: inherit; - font-size: 13px; - background: none; - color: inherit; + font-size: 14px; + line-height: 22px; + background: transparent; + color: var(--dsw-alias-label-primary); cursor: pointer; } .creatorButton:hover:not(:disabled) { - background: var(--dsw-alias-bg-layer-1); + background: var(--dsw-alias-interactive-bg-hover); } .creatorButton:disabled { - opacity: 0.5; + opacity: 0.4; cursor: default; } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index f5a31fcdf8..4580f436bc 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -171,6 +171,30 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { ) } + /* The guided alternative to copying: the self-referential preset can + read this very composition and author a new one in conversation. + Offered only where that preset is actually on the roster and a + session can be landed; without a writable root the draft could + never be discovered, so the reason rides the disabled button. */ + const creatorButton = props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') + ? ( + + ) + : null + return (

{t('nav')}

@@ -180,147 +204,130 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { const group = state.rows .filter(row => row.trust === trust) .map(row => ({ row, text: presetDisplayText(row, t) })) - if (group.length === 0) return null + // The custom group is where a preset of one's own will appear, so it + // stays on screen even while empty: heading plus the creator entry. + const tail = trust === 'user' ? creatorButton : null + if (group.length === 0 && tail === null) return null return (

{heading}

-
    - {group.map(({ row, text }) => ( -
  • - {/* The card body IS the control: picking a preset is the + {group.length === 0 ? null : ( +
      + {group.map(({ row, text }) => ( +
    • + {/* The card body IS the control: picking a preset is the common act, so it should not hide behind a small button. The action row sits outside it — nesting buttons is invalid, and these act on the card rather than select it. A broken preset cannot compose a session, so its body is disabled and the card says why instead of offering it. */} - -
      - {/* Shipped presets are the compositions a copy starts + {text.description ?? t('noDescription')} + {row.broken === undefined + ? null + : {row.broken}} + {row.id} + +
      + {/* Shipped presets are the compositions a copy starts from, so READING one is the point; a custom preset is edited in its files instead, which the location action leads to. A broken shipped preset has no readable composition to offer, so its viewer is withheld; a broken custom one keeps the location action — the files are where it gets fixed. */} - {row.trust === 'system' - ? row.broken === undefined - ? ( + {row.trust === 'system' + ? row.broken === undefined + ? ( + + ) + : null + : ( + )} + + {row.trust === 'user' + ? ( + ) - : null + : null} +
      + {state.revealedPaths[row.id] === undefined + ? null : ( - +

      + {t('revealedPathLabel')} + {state.revealedPaths[row.id]} +

      )} - - {row.trust === 'user' - ? ( - - ) - : null} -
      - {state.revealedPaths[row.id] === undefined - ? null - : ( -

      - {t('revealedPathLabel')} - {state.revealedPaths[row.id]} -

      - )} -
    • - ))} -
    +
  • + ))} +
+ )} + {tail}
) })} - {/* The guided alternative to copying: the self-referential preset can - read this very composition and author a new one in conversation. - Offered only where that preset is actually on the roster and a - session can be landed; without a writable root the draft could - never be discovered, so the reason rides the disabled button. */} - {props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') - ? ( - - ) - : null} seat.load(), select: (id: string) => seat.select(id), + introduced: () => { seat.introduced() }, }) const labelInjected = (): AgentPresetLabelInjected => ({ @@ -146,7 +147,9 @@ export function apply(ctx: ClientContext): void { // on: the chip's list-change applier composes the blank session the // workspace connect produces or reuses. creatorDraft = () => { - seat.stage('cordis') + // The introduce cue makes the chip announce the pick the user never + // made on this screen — the stage happened back in settings. + seat.stage('cordis', true) scope.workspaces.startSession() } const chip = scope.slots.register({ diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 27a414e4a3..ab973ec5b5 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -26,10 +26,16 @@ export interface AgentPresetSeatState { /** A rejected apply's message, cleared by the next attempt. */ error: string | null busy: boolean + /** + * One-shot cue that the chip should introduce itself (the creator-draft + * entry staged the pick from another screen, so the user never touched the + * chip); the renderer clears it via `introduced()` once played. + */ + introduce: boolean } const INITIAL: AgentPresetSeatState = { - options: [], current: '', error: null, busy: false, + options: [], current: '', error: null, busy: false, introduce: false, } /** One session's identity and whether it has started. */ @@ -121,10 +127,18 @@ export class AgentPresetSeatController { * list-change applier, which fires when the started session becomes * current. * @param id - the preset to stage. + * @param introduce - true when the stage came from another screen and the + * chip should announce itself on the session it lands on. */ - stage(id: string): void { + stage(id: string, introduce = false): void { this.staged = id - this.set({ current: id, error: null }) + this.set({ current: id, error: null, introduce }) + } + + /** Acknowledge the introduction cue once the chip has played it. */ + introduced(): void { + if (!this.store.getSnapshot().introduce) return + this.set({ introduce: false }) } /** diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx index 8a37a7af43..b63b9ce63c 100644 --- a/packages/client/ui-agent-preset/tests/components.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -41,6 +41,7 @@ const SEAT_READY: AgentPresetSeatState = { ], busy: false, error: null, + introduce: false, } function renderRow(state: Partial = {}) { @@ -56,7 +57,11 @@ function renderRow(state: Partial = {}) { function renderSeat(state: Partial = {}) { const store = createSnapshotStore({ ...SEAT_READY, ...state }) - const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } + const actions = { + load: vi.fn(() => Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + introduced: vi.fn(), + } render({permissionGlyph(currentValue)} )} {current === undefined ? displayName(currentValue) : optionLabel(current)} - {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} - - - + {/* Same glyph + open rotation as the sibling ModelSelect trigger; + class on the svg itself — an inline wrapper span leaves + baseline descent under the icon and floats it off-center. */} + } /> diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 02f4913751..354adc4454 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -349,6 +349,35 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** ic_ds_agent_preset_outline_16. The three node interiors knock out to transparency via mask so the glyph sits on any background. */ +export const IconAgentPresetOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + + + + + +) + /** ic_ds_browse_outline_16 */ export const IconBrowseOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 9163e68ba8..04e8c98cd6 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -73,7 +73,7 @@ z-index: 1; display: flex; width: 800px; - height: min(800px, calc(100vh - 48px)); + height: min(824px, calc(100vh - 48px)); max-width: calc(100vw - 48px); border-radius: 24px; overflow: hidden; @@ -205,11 +205,11 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */ +/* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */ .options { flex: 1; min-height: 0; - padding: 0 24px 8px; + padding: 0 24px 24px; overflow-y: auto; } diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 54e0e0dbb7..de00fa372e 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -14,7 +14,7 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' import { - IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16, + IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import css from './SettingsRoot.module.css' @@ -22,7 +22,7 @@ import css from './SettingsRoot.module.css' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ function navIcon(id: string) { if (id === 'models') return - if (id === 'agent-presets') return + if (id === 'agent-presets') return return } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index a98d8ea26e..67310853a2 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -84,7 +84,7 @@ gap: 8px; height: 60px; padding: 8px 0 8px 4px; - margin-bottom: 16px; + margin-bottom: 8px; box-sizing: border-box; overflow: hidden; } @@ -157,8 +157,8 @@ color: var(--dsw-alias-label-primary); } -/* New Session: 38px capsule (figma 133:7634); collapsed it renders as the - rail's plain icon control. */ +/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off + corners); collapsed it renders as the rail's plain icon control. */ .newSession { flex: none; display: flex; @@ -170,7 +170,7 @@ margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */ box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; + border-radius: 12px; background: var(--dsw-alias-button-elevated-fill); color: var(--dsw-alias-label-primary); font-size: 14px; diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 3a6ace14a5..2a730245e7 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -519,8 +519,14 @@ export function SubagentCatalogAction({ observedCatalogs.current.clear() }, []) + // Visibility needs evidence of children (entries, summary-known descendants, + // or a failed load worth retrying). A bare loading catalog is not evidence: + // selecting any session schedules a refresh whose loading snapshot would + // otherwise flash the action in and out on childless sessions. const visible = presentedCatalog !== undefined - && (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0) + && (presentedCatalog.state === 'error' + || presentedCatalog.entries.length > 0 + || descendantCount > 0) useEffect(() => { if (visible || !open) return setOpen(false) diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index c300e423e6..ae6c0f07ee 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -522,22 +522,23 @@ describe('SubagentCatalogAction', () => { expect(staleEmpty.openChild).not.toHaveBeenCalled() }) - it('renders empty loading and fallback error states without focusable rows', async () => { + it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => { + // Selecting any session schedules a catalog refresh; a loading snapshot + // with no other evidence of children must not flash the action in. const loading = props(catalog({ entries: [], state: 'loading' })) const view = render() - const trigger = screen.getByRole('button', { name: /0 个子代理/ }) - fireEvent.click(trigger) - expect(screen.getByText('正在加载子代理…')).toBeTruthy() - fireEvent.keyDown(trigger, { key: 'ArrowDown' }) - await Promise.resolve() - expect(screen.getByRole('tree')).toBeTruthy() - fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' }) + expect(screen.queryByRole('button')).toBeNull() view.unmount() const failed = props(catalog({ entries: [], state: 'error', error: null })) render() - fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ })) + const trigger = screen.getByRole('button', { name: /0 个子代理/ }) + fireEvent.click(trigger) expect(screen.getByText('无法加载子代理')).toBeTruthy() + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + await Promise.resolve() + expect(screen.getByRole('tree')).toBeTruthy() + fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' }) }) it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 6052b5075f..6c6c44c2c7 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -64,7 +64,8 @@ line-height: 20px; } -/* Search input: 38px capsule (figma 133:7649); rail state renders it as the +/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off + corners); rail state renders it as the region's search control. Upstream binds a dedicated design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token pinned to the static scale mirrors it. */ @@ -79,7 +80,7 @@ padding: 0 14px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; + border-radius: 12px; background: var(--dsh-search-input-fill); color: var(--dsw-alias-label-caption); overflow: hidden; From a95e3265f6b596f2cec47b594c7ff967004c2fd9 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:41:32 +0800 Subject: [PATCH 19/77] fix(web): polish preset chrome and subagent menu after review --- .../ui-agent-preset/src/client/AgentPresetLabel.module.css | 2 +- .../client/ui-agent-preset/src/client/AgentPresetLabel.tsx | 2 +- .../src/client/AgentPresetSection.module.css | 6 ++++++ .../src/client/skeleton/ConversationRoot.module.css | 1 + .../client/ui-settings/src/client/SettingsRoot.module.css | 2 +- .../ui-subagent/src/client/SubagentCatalogAction.module.css | 1 - 6 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css index 5468f0d592..6d2cdd814b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -5,7 +5,7 @@ align-items: center; gap: 4px; max-width: 180px; - padding: 0 8px; + padding: 0 2px 0 0; height: 22px; border-radius: 6px; background: var(--dsw-alias-fill-tsp-secondary); diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index fb4b56490c..3e98310cca 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -57,7 +57,7 @@ export function AgentPresetLabel({ const text = option === undefined ? undefined : presetDisplayText(option, t) return ( - + {text?.name ?? preset} ) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 79dca43f7b..0438e23b17 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -26,6 +26,12 @@ gap: 10px; } +/* Group-to-group breathing room: the section's 12px gap plus 20px reads the + two rosters as separate blocks (32px total). */ +.group + .group { + margin-top: 20px; +} + .groupHead { margin: 0; font-size: 12px; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index ca15f77c4d..040e656bd8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -292,6 +292,7 @@ .heroWorkspaceRow { display: flex; align-items: center; + gap: 2px; min-width: 0; /* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to the card's inner controls below. */ diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 04e8c98cd6..f1bd87e9af 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -73,7 +73,7 @@ z-index: 1; display: flex; width: 800px; - height: min(824px, calc(100vh - 48px)); + height: min(800px, calc(100vh - 48px)); max-width: calc(100vw - 48px); border-radius: 24px; overflow: hidden; diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index fc3ddfea46..75f0040cf6 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -54,7 +54,6 @@ max-height: min(560px, calc(100vh - 140px)); padding: 4px; overflow: auto; - border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-specific-menu); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); From 4bee3f73ef887f35825fc8b155a0d6e36646d1f1 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:49:45 +0800 Subject: [PATCH 20/77] fix(web): tighten hero row spacing and round the hero chips --- .../ui-agent-preset/src/client/AgentPresetSeat.module.css | 2 +- .../src/client/skeleton/ConversationRoot.module.css | 6 ++++-- .../src/client/skeleton/HeroShell.module.css | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index 93d9f2b6fe..0763ffff02 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -9,7 +9,7 @@ min-height: 28px; padding: 0 8px; border: none; - border-radius: 12px; + border-radius: 16px; background: transparent; color: var(--dsw-alias-label-primary); font-size: 13px; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 040e656bd8..932fd8e4e8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -263,8 +263,9 @@ .composerHero { position: relative; /* .heroGlow positioning context */ align-self: center; - /* figma 75:8208: 12 between hero chrome / workspace row / card. */ - gap: 12px; + /* figma 75:8208 drew 12 between all three rows; the workspace row now sits + 6 above the card (its margin-top restores 12 under the hero chrome). */ + gap: 6px; /* Foot inside the centered box floats the stack a bit above true center. */ padding-bottom: 32px; /* Card cap + both clearances: the hero input card lands at exactly the same @@ -294,6 +295,7 @@ align-items: center; gap: 2px; min-width: 0; + margin-top: 6px; /* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to the card's inner controls below. */ padding-left: 20px; diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 0e730a5b30..3d9281b96b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -105,7 +105,7 @@ min-height: 28px; padding: 0 8px; border: none; - border-radius: 12px; + border-radius: 16px; background: transparent; color: var(--dsw-alias-label-primary); font-size: 13px; From 021ecb53c5b3781d959fb4ab8195cf13c2b463be Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:52:23 +0800 Subject: [PATCH 21/77] fix(web): set hero row-to-card spacing to 8 --- .../src/client/skeleton/ConversationRoot.module.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 932fd8e4e8..971661cd48 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -264,8 +264,8 @@ position: relative; /* .heroGlow positioning context */ align-self: center; /* figma 75:8208 drew 12 between all three rows; the workspace row now sits - 6 above the card (its margin-top restores 12 under the hero chrome). */ - gap: 6px; + 8 above the card (its margin-top restores 12 under the hero chrome). */ + gap: 8px; /* Foot inside the centered box floats the stack a bit above true center. */ padding-bottom: 32px; /* Card cap + both clearances: the hero input card lands at exactly the same @@ -295,7 +295,7 @@ align-items: center; gap: 2px; min-width: 0; - margin-top: 6px; + margin-top: 4px; /* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to the card's inner controls below. */ padding-left: 20px; From 4f23fa84ccc8c014050bd0fde221ecf990282ce3 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 22:19:14 +0800 Subject: [PATCH 22/77] fix(subagent): complete output selection contract --- ...nt-empty-terminal-message-output.i18n.yaml | 4 +-- ...-subagent-empty-terminal-message-output.md | 16 +++++---- ...bagent-empty-terminal-message-output.zh.md | 16 +++++---- docs/subsystems/subagent.i18n.yaml | 4 +-- docs/subsystems/subagent.md | 8 ++--- docs/subsystems/subagent.zh.md | 8 ++--- examples/acp-agent/tests/acp.snapshot.ts | 9 ++--- .../scaffold/client/tests/fake-runtime.ts | 10 +++--- packages/scaffold/protocol/README.i18n.yaml | 4 +-- packages/scaffold/protocol/README.md | 2 +- packages/scaffold/protocol/README.zh.md | 2 +- packages/scaffold/protocol/src/types.ts | 2 +- .../server/tests/built-scope-carrier.e2e.ts | 4 +-- packages/scaffold/server/tests/server.spec.ts | 4 +-- packages/subagent/subagent-acp/src/run.ts | 5 ++- .../subagent-dsh-sdk/README.i18n.yaml | 4 +-- packages/subagent/subagent-dsh-sdk/README.md | 2 +- .../subagent/subagent-dsh-sdk/README.zh.md | 2 +- .../tests/subagent-dsh-sdk.spec.ts | 2 +- .../subagent-inprocess/README.i18n.yaml | 4 +-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../tests/subagent-inprocess.spec.ts | 6 ++-- packages/subagent/subagent/README.i18n.yaml | 4 +-- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/assistant-output.ts | 19 +++++----- packages/subagent/subagent/src/lifecycle.ts | 3 +- packages/subagent/subagent/src/types.ts | 8 ++--- .../subagent/tests/assistant-output.spec.ts | 35 ++++++++++++++++--- .../subagent/tests/continuation.spec.ts | 7 ++-- .../subagent/subagent/tests/service.spec.ts | 4 +-- 32 files changed, 113 insertions(+), 93 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml index 537cb88062..612916a290 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.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-08-10-subagent-empty-terminal-message-output.md -2026-08-10-subagent-empty-terminal-message-output.md: d90047c07a300a1afbc42c7db1a4fefa25d56764 -2026-08-10-subagent-empty-terminal-message-output.zh.md: 0a5ce02dccef422dc75bc980d104f41f116427f2 +2026-08-10-subagent-empty-terminal-message-output.md: 693013f6810005ce02b08bd82f1f6a18511c40fb +2026-08-10-subagent-empty-terminal-message-output.zh.md: 64d61af21f838ef3f515db8af116cbdd74e96179 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md index d90047c07a..693013f681 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md @@ -6,24 +6,26 @@ English | [中文](2026-08-10-subagent-empty-terminal-message-output.zh.md) ## Problem -The agent loop appends an EMPTY-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks (`BlockAssembler.blocks()` drops truncated tool calls): the message exists solely to host usage. Three consumers each selected "the child's answer" with their own rule and all treated that usage host as the answer. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture took the LAST `assistant/message` unfiltered, and the SDK backend's observer let any `assistant/message` beat its streamed-text fallback. In a multi-step turn cut off at max-tokens, the final empty message therefore erased the real partial answer: `SubagentResult.output` came back `[]`, and the tool result, telemetry, and `subagent/end.lastAssistantMessage` all saw nothing. The in-process driver additionally had no streamed-text fallback at all, so a cancelled child whose only text lived in `assistant/chunk` events also reported `[]`. +The agent loop appends an empty-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks because `BlockAssembler.blocks()` drops truncated tool calls; the message records usage only. Three consumers selected the child's output independently and treated that usage record as output. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture selected the last `assistant/message` without filtering, while the SDK backend's observer let any `assistant/message` take precedence over accumulated text. In a multi-step turn cut off at max-tokens, the final empty message caused the real partial answer to be omitted from `SubagentResult.output`, the tool result, telemetry, and `subagent/end.lastAssistantMessage`. The in-process driver also lacked a streamed-text fallback, so a cancelled child whose only text existed in `assistant/chunk` events reported `[]`. ## Decision -`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: the last NON-EMPTY assistant message wins; without one, the accumulated `text-delta` stream is the answer; empty-content messages never contribute. The rule has one implementation, the incremental `AssistantOutputFold` (`push(event)` for session-event transports, `pushText(text)` for chunk-only transports, `collect()` to select), and `finalAssistantOutput(events)` applies it to a complete event suffix (the in-process `readResult` and the Activation capture). The SDK backend folds notification events; the ACP backend, which surfaces no complete assistant messages, folds raw chunk text into the same streamed fallback. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` selects by the same rule, and "no output" has one encoding on that edge — the field is absent, never an empty array, on both the one-shot and continuable lifecycle shapes. A `max-tokens` or `aborted` finish still reports its honest stop reason; only output selection changed. +`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason. -The foreground delegation tool observes the same selection: a non-`completed` result stays an `isError` tool result, but its message appends the child's preserved partial text after the stop-reason headline, so the parent model sees the truncated answer instead of a bare failure. +The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message appends the child's partial text after the stop-reason headline so the parent model receives both the failure and available output. -The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message, and the authored `subagent-max-tokens-partial` ACP snapshot scenario pins the assembled transcript: a scripted child streams text plus a tool call, is cut off by a tool-only max-tokens step (the empty usage-only message appears in its committed log), and the parent's tool result carries the partial answer. +## Verification + +The keyless SDK backend test uses `FAKE_EMPTY_MESSAGE` to emit a usage-only terminal message. The `subagent-max-tokens-partial` ACP snapshot records a child that streams text and a tool call, ends at a tool-only max-tokens step with an empty usage message in its durable log, and returns the partial text through the parent's errored tool result. Unit coverage checks empty terminal messages, cancellation, message ordering, textless non-empty messages, and exclusion of tool-result content. ## Alternatives considered -**Fix each consumer in place without a shared helper.** Rejected: the defect existed precisely because three hand-rolled selections drifted; observers of one run must agree on its answer, so the rule needs one implementation (the drafts that first proved the defect, PR #1140 and PR #1141, patched two of the three call sites separately and left the Activation capture inconsistent). +**Fix each consumer in place without a shared helper.** Rejected: three independent selections had diverged, while observers of one run must agree on its output. -**Stop the loop from appending the empty message.** Rejected: the message is the usage host and the step's durable record ("model-visible ⟺ logged"); reshaping session events for a consumer-side selection bug would touch every replay and projection consumer. +**Stop the loop from appending the empty message.** Rejected: the message records usage and preserves the step in the durable log ("model-visible ⟺ logged"); changing session events to address output selection would affect every replay and projection consumer. **Treat empty-content messages as an error.** Rejected: the streamed text is the child's real partial answer, and the stop reason already tells the consumer the turn was cut short. ## Consequences -Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children keep the text streamed before the abort; one-shot and continuable `subagent/end` edges agree with `SubagentResult.output`. A message whose content is non-empty but textless (for example reasoning-only) still wins over streamed text — the rule is about empty content, not text presence. A non-empty message also wins over text streamed AFTER it: a child cancelled while streaming a later step reports its earlier complete message, matching the SDK backend's documented contract, with the stop reason signalling the truncation. Regression tests in all three packages script the empty-terminal-message and cancel paths and fail under the previous selections. +Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children retain text streamed before the abort; one-shot and continuable `subagent/end` events agree with `SubagentResult.output`. A message whose content is non-empty but textless, such as reasoning-only content, is selected instead of streamed text because the rule tests content length rather than text presence. A non-empty message is also selected instead of text streamed after it: a child cancelled while streaming a later step reports its earlier complete message, while the stop reason records the truncation. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md index 0a5ce02dcc..64d61af21f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md @@ -6,24 +6,26 @@ Status: implemented ## 问题 -当 `max-tokens` 步骤只组装了工具调用块时(`BlockAssembler.blocks()` 会丢弃被截断的工具调用),agent loop 会追加一条内容为**空**的 `assistant/message`——这条消息仅用于承载 usage。三个消费方各自用自己的规则选取"子代理的回答",并且都把这个 usage 宿主当成了回答:进程内驱动的 `readResult` 和 continuable Activation 的 `subagent/end` capture 不加过滤地取**最后一条** `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 覆盖其流式文本兜底。于是在被 max-tokens 截断的多步回合中,最后那条空消息抹掉了真实的部分回答:`SubagentResult.output` 返回 `[]`,工具结果、遥测和 `subagent/end.lastAssistantMessage` 全都看不到任何内容。此外进程内驱动完全没有流式文本兜底,因此被取消的子代理若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。 +当 `max-tokens` 步骤只组装了工具调用块时,agent loop(智能体循环)会追加一条空内容的 `assistant/message`,因为 `BlockAssembler.blocks()` 会丢弃被截断的工具调用;这条消息仅记录 usage。三个消费方独立选取子 agent 的输出,并把这条 usage 记录当成输出。进程内驱动的 `readResult` 与 continuable Activation 的 `subagent/end` capture 不加过滤地选取最后一条 `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 优先于累积的文本。在被 max-tokens 截断的多步轮次中,最后那条空消息导致 `SubagentResult.output`、工具结果、遥测与 `subagent/end.lastAssistantMessage` 都漏掉真实的部分回答。进程内驱动也没有流式文本兜底,因此被取消的子 agent 若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。 ## 决策 -`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:最后一条**非空** assistant 消息优先;没有时,累积的 `text-delta` 流就是回答;空内容消息从不参与。规则只有一个实现,即增量的 `AssistantOutputFold`(会话事件传输用 `push(event)`,仅分块传输用 `pushText(text)`,`collect()` 完成选取);`finalAssistantOutput(events)` 把它应用于完整的事件后缀(进程内 `readResult` 与 Activation capture)。SDK 后端折叠通知事件;ACP 后端不产生完整 assistant 消息,因此把原始分块文本折叠进同一个流式兜底。契约在 `SubagentResult.output` 处声明一次,并由子系统参考文档镜像;`subagent/end.lastAssistantMessage` 按同一规则选取,且"无输出"在该边沿只有一种编码——字段缺省,绝不是空数组,一次性与 continuable 两种生命周期形态一致。`max-tokens` 或 `aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。 +`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens` 或 `aborted` 结果保留实际的终止原因。 -前台委派工具观察同一选取结果:非 `completed` 的结果仍是 `isError` 工具结果,但其消息在终止原因标题之后附带子代理保留下来的部分文本,父模型看到的是被截断的回答而不是一句干巴巴的失败。 +前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后附上子 agent 的部分文本,让父模型同时接收失败信息与已有输出。 -fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息;authored 的 `subagent-max-tokens-partial` ACP snapshot 场景钉住了组装后的 transcript:脚本化的子代理先流式输出文本和一次工具调用,再被仅含工具调用的 max-tokens 步骤截断(空的 usage-only 消息出现在其提交的日志中),父侧工具结果携带部分回答。 +## 验证 + +无密钥 SDK 后端测试使用 `FAKE_EMPTY_MESSAGE` 发出一条仅记录 usage 的终止消息。`subagent-max-tokens-partial` ACP 快照记录一个子 agent:它流式输出文本与一次工具调用,结束于仅含工具调用的 max-tokens 步骤,持久化日志中含一条空的 usage 消息,并通过父侧的错误工具结果返回部分文本。单元覆盖检查空终止消息、取消、消息顺序、不含文本的非空消息,以及排除工具结果内容。 ## 考虑过的替代方案 -**各消费方就地修复、不抽共享辅助函数。** 之所以否决:缺陷恰恰源于三处手写选取的漂移;同一次运行的观察方必须对其回答达成一致,因此规则需要唯一实现(最早证明该缺陷的草稿 PR #1140 与 PR #1141 分别修补了三处调用点中的两处,留下 Activation capture 不一致)。 +**各消费方就地修复、不抽共享辅助函数。** 之所以否决:三处独立选取已发生分歧,而同一次运行的观察方必须对其输出达成一致。 -**让 loop 不再追加空消息。** 之所以否决:这条消息是 usage 宿主,也是该步骤的持久化记录("model-visible ⟺ logged");为一个消费方侧的选取缺陷重塑会话事件,会波及所有 replay 与 projection 消费方。 +**让 loop 不再追加空消息。** 之所以否决:这条消息记录 usage,并在持久化日志中保留该步骤("model-visible ⟺ logged");为处理输出选取而改动会话事件,会影响所有 replay 与 projection 消费方。 **把空内容消息视为错误。** 之所以否决:流式文本才是子代理真实的部分回答,且终止原因已经告诉消费方轮次被截断。 ## 后果 -被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。非空消息同样优先于**其后**才流式出的文本:子代理在流式后续步骤时被取消,报告的是更早那条完整消息,与 SDK 后端文档化的契约一致,截断由终止原因示意。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。 +被 max-tokens 截断的多步子 agent 会报告其更早的文本;被取消的进程内子 agent 保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 事件同 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning 的内容)仍然优先于流式文本,因为规则检查内容长度,而不是文本是否存在。非空消息同样优先于其后才流式出的文本:子 agent 在流式输出后续步骤时被取消,报告的是更早那条完整消息,终止原因则记录该截断。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 0e8c73e9c5..d1d97c5515 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subagent.md -subagent.md: d04e63246f2cd3f35792905574425b3013910b8a -subagent.zh.md: 61f7330988820ddf86e7d2f8b1acc92b434768e6 +subagent.md: 273745057a750a0cdbd9c829e3922ed43398b861 +subagent.zh.md: 3d1bed59bee17900bc04156fb8bb854cab6ca1e0 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index d04e63246f..273745057a 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -294,10 +294,10 @@ The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is */ interface SubagentResult { /** - * The child's final assistant output: the content of the last NON-EMPTY - * assistant message (an empty-content message hosts only usage and is - * skipped), else the text streamed before the turn was cut short, or `[]` - * when the child produced none. + * The child's final assistant output is the content of its last non-empty + * assistant message. Empty-content messages, including usage-only messages, + * are skipped. Without a non-empty message, the output is its accumulated + * assistant text stream, or `[]` when the child produced neither. */ readonly output: ContentBlock[] /** diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 61f7330988..3d1bed59be 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -294,10 +294,10 @@ type SubagentDescendantListEntry = SubagentListEntry & { */ interface SubagentResult { /** - * The child's final assistant output: the content of the last NON-EMPTY - * assistant message (an empty-content message hosts only usage and is - * skipped), else the text streamed before the turn was cut short, or `[]` - * when the child produced none. + * The child's final assistant output is the content of its last non-empty + * assistant message. Empty-content messages, including usage-only messages, + * are skipped. Without a non-empty message, the output is its accumulated + * assistant text stream, or `[]` when the child produced neither. */ readonly output: ContentBlock[] /** diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 8cf0deeb1e..f06bf1fbf4 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -315,12 +315,9 @@ const SCENARIOS: Scenario[] = [ // Windows bash process-tree kill is deferred with the Bash execution domain. { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true }, { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, - // Keyless, authored (like error-finish): a live child cannot be coaxed into - // a max-tokens step that assembled ONLY tool-call blocks — the truncation - // shape whose usage-only empty assistant/message must not erase the child's - // earlier text. The child fixture scripts text + todo_write, then a - // tool-only max-tokens cutoff; the parent's subagent tool result must carry - // the child's real partial answer with the max-tokens stop reason. + // Keyless authored scenario: the child ends at max-tokens with an empty + // usage-only assistant/message after earlier text and a tool call. The + // parent's tool result must retain that assistant output and stop reason. { name: 'subagent-max-tokens-partial', hasModelTurn: true, recorded: false }, { name: 'subagent-multi', hasModelTurn: true, recorded: true }, { name: 'subagent-fork', hasModelTurn: true, recorded: true }, diff --git a/packages/scaffold/client/tests/fake-runtime.ts b/packages/scaffold/client/tests/fake-runtime.ts index 0462fabf4a..4fb2b6017a 100644 --- a/packages/scaffold/client/tests/fake-runtime.ts +++ b/packages/scaffold/client/tests/fake-runtime.ts @@ -26,9 +26,8 @@ * array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data * member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare * string (wire-validation probes). - * - `FAKE_EMPTY_MESSAGE`: the turn's assistant/message has EMPTY content (a - * usage-only max-tokens step) after streaming the text chunk — a consumer - * must keep the streamed text instead of the empty message. + * - `FAKE_EMPTY_MESSAGE`: the turn streams a text chunk, then records an empty + * assistant/message for a usage-only max-tokens step. * - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe). * - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize` * arrives, then poll for the GO file before answering (deterministic @@ -120,9 +119,8 @@ function runTurn(sessionId: string): void { message: { id: `fake-assistant-${seq}`, role: 'assistant', - // FAKE_EMPTY_MESSAGE: a usage-only terminal message (the harness loop - // appends one when a max-tokens step assembled no text blocks) whose - // empty content must not erase the text streamed above. + // Model the usage-only message recorded after a max-tokens step that + // assembled no output blocks. content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }], source: { kind: 'model', provider: 'fake', model: 'fake' }, }, diff --git a/packages/scaffold/protocol/README.i18n.yaml b/packages/scaffold/protocol/README.i18n.yaml index f038434410..541155d37c 100644 --- a/packages/scaffold/protocol/README.i18n.yaml +++ b/packages/scaffold/protocol/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/scaffold/protocol/README.md -README.md: 88a48957d0d44cec9f776d31eab7d25bd353de5f -README.zh.md: 6618d8838a00f945c79d7ec24b1e7491df08a3f1 +README.md: 082a890454f900aec51df123669f28814d39d601 +README.zh.md: d9b8460e51b5313f4c3a8ac66471e8cd39142430 diff --git a/packages/scaffold/protocol/README.md b/packages/scaffold/protocol/README.md index 88a48957d0..082a890454 100644 --- a/packages/scaffold/protocol/README.md +++ b/packages/scaffold/protocol/README.md @@ -22,7 +22,7 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) | -`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. +`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `SubagentFinishedNotification.lastAssistantMessage` contains the child's last non-empty assistant message or, when no such message exists, its accumulated assistant text; the field is absent when the child produced neither. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. ## Model Experience diff --git a/packages/scaffold/protocol/README.zh.md b/packages/scaffold/protocol/README.zh.md index 6618d8838a..d9b8460e51 100644 --- a/packages/scaffold/protocol/README.zh.md +++ b/packages/scaffold/protocol/README.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按 | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) | -`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。 +`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`SubagentFinishedNotification.lastAssistantMessage` 包含子 agent 最后一条非空 assistant 消息;若不存在这类消息,则包含其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。 ## 模型体验 diff --git a/packages/scaffold/protocol/src/types.ts b/packages/scaffold/protocol/src/types.ts index dc8e11587f..16af2a76ac 100644 --- a/packages/scaffold/protocol/src/types.ts +++ b/packages/scaffold/protocol/src/types.ts @@ -85,7 +85,7 @@ export interface SubagentFinishedNotification { status: SdkRunStatus /** The provider-reported stop reason. */ stopReason: SubagentStopReason - /** The child's final assistant message, when it produced one. */ + /** The child's selected assistant output; absent when the child produced none. */ lastAssistantMessage?: ContentBlock[] } diff --git a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts index fdd5276352..a51c88ddb3 100644 --- a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts +++ b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts @@ -106,8 +106,8 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', ( }) expect(stderr).not.toContain('listener threw') - // A childless result carries NO lastAssistantMessage on the wire: the end - // edge encodes "no output" as an absent field, never `[]`. + // A result without output omits lastAssistantMessage from the wire; it + // never sends `[]`. expect(JSON.parse(stdout) as unknown).toEqual([{ method: 'subagent.finished', params: { diff --git a/packages/scaffold/server/tests/server.spec.ts b/packages/scaffold/server/tests/server.spec.ts index 7b375f1a97..bdad715d4f 100644 --- a/packages/scaffold/server/tests/server.spec.ts +++ b/packages/scaffold/server/tests/server.spec.ts @@ -736,8 +736,8 @@ describe('HarnessSdkServer', () => { stopReason: 'error', }) - // A childless result carries NO lastAssistantMessage on the wire: the - // end edge encodes "no output" as an absent field, never `[]`. + // A result without output omits lastAssistantMessage from the wire; it + // never sends `[]`. expect(transport.notifications).toContainEqual({ method: 'subagent.finished', params: { diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index e5c0249433..38329244ba 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -233,9 +233,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe let processDisposal: Promise | undefined const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs)) - // The child's streamed assistant text, accumulated under the seam's - // canonical selection rule (`AssistantOutputFold`); ACP surfaces no complete - // assistant messages, so only the streamed-fallback half applies. + // ACP exposes no complete assistant messages, so the shared fold selects its + // accumulated assistant text. const fold = new AssistantOutputFold() // Shared mutable state keeps cancellation visible across async closures. const flags = { cancelled: false } diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index cbe3becb25..0d7e60cc46 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/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/subagent/subagent-dsh-sdk/README.md -README.md: 80f5c40a2c949b7c2a638ec19c02950e8cd69f0b -README.zh.md: b34421dbbf2d06b7c9236776aabf19ba8005204e +README.md: 493bb187d45c7654958cfb3dbbe1dee6bb21b368 +README.zh.md: 2e1d9b1e602f2180d20d43fe8c358163ec4ec024 diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 80f5c40a2c..493bb187d4 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -10,7 +10,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. -The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete NON-EMPTY `assistant/message` (an empty-content message hosts only usage and is skipped), or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths. +The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Partial output remains available after cancellation or an error. `dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit. diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index b34421dbbf..2e1d9b1e60 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -10,7 +10,7 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS 工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。 -返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且**非空**的 `assistant/message`(空内容消息仅承载 usage,会被跳过),或该活动中断前已经累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。 +返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空的 `assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta` 流。取消或发生错误后,部分输出仍然可用。 `dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。 diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index c00dfe5fec..da95c5a977 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -176,7 +176,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { await ctx.fiber.dispose() }) - it('keeps streamed text when the terminal message is an EMPTY usage-only step', async () => { + it('keeps streamed text when the terminal message is an empty usage-only step', async () => { // The child streams its answer, then emits an empty-content // assistant/message (the harness loop appends one to host usage on a // max-tokens step that assembled no text blocks). The empty message is diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index ed5606cbc9..4a39ea60f9 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 2e2a3873843467b0811ccbc0ed1d9bb6a83eb31f -README.zh.md: 91013164e762bb01e7ad5a51597c6fa559c8d7a3 +README.md: fd5129b044b3d9008c3ba73645f6de36ddbf35dc +README.zh.md: 8f7ff137f04183b50acbe96bcd20a4023dfb86f4 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 2e2a387384..fd5129b044 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ The driver follows this sequence: 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. Read the child's own output — its last NON-EMPTY assistant message (an empty-content message hosts only usage and is skipped), else the text it streamed before cancel or truncation cut the turn short — and the final durable turn reason from the complete owned child run, excluding any fork seed. +5. Read the child's own output — its last non-empty assistant message (an empty-content message that records usage is skipped), or its accumulated assistant text when no such message exists — and the final durable turn reason from the complete owned child run, excluding any fork seed. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 91013164e7..8f7ff137f0 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条**非空** assistant 消息(空内容消息仅承载 usage,会被跳过),否则取轮次被取消或截断前已流式的文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 +5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条非空 assistant 消息(记录 usage 的空内容消息会被跳过),若没有这类消息则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index ebbc893b03..ef92dea15a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -157,10 +157,8 @@ describe('startInProcessRun', () => { }) it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => { - // Step 1 streams "partial one" plus a tool call; step 2 hits max-tokens - // having assembled only a tool-call block, so the loop appends an EMPTY - // assistant/message to host usage. The empty message is not assistant - // output and must not erase step 1's text from the run's output. + // A tool-only max-tokens step records an empty assistant/message for + // usage. The result retains the preceding assistant output. const { ctx, parent } = await setup([ toolCallResponse('t1', 'noop', {}, 'partial one'), [ diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 531f924cec..6166075110 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 3bc5bc1c07077f21a4245c3ce08470bc976c1458 -README.zh.md: 90fde6b4403891edf910c45327c9b0f629980303 +README.md: b9757bb04609d3cdd1459d5845e5c59388b269f6 +README.zh.md: 9d28d057e08ddb12530ba2894eda8046a5c0ff5a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 3bc5bc1c07..b9757bb046 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -60,7 +60,7 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `provider.start(request): Promise` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` edge's `lastAssistantMessage` share one selection rule, implemented once by the exported `AssistantOutputFold`/`finalAssistantOutput` helpers: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the contract). +`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the result contract). A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 90fde6b440..9d28d057e0 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -60,7 +60,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 边沿的 `lastAssistantMessage` 共用同一条选取规则,由导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数唯一实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(结果约定归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。 diff --git a/packages/subagent/subagent/src/assistant-output.ts b/packages/subagent/subagent/src/assistant-output.ts index a617060390..6701327cfd 100644 --- a/packages/subagent/subagent/src/assistant-output.ts +++ b/packages/subagent/subagent/src/assistant-output.ts @@ -1,12 +1,11 @@ /** - * Canonical selection of a child's final assistant output. Every surface that - * reports "the child's answer" — backend run results and - * `subagent/end.lastAssistantMessage` — applies this one rule so observers - * agree: the last NON-EMPTY assistant message wins; an empty-content message - * hosts only usage (the loop appends one when a max-tokens step assembled no - * executable blocks) and never erases real output; without any non-empty - * message, the text streamed so far is the answer (a partial surviving - * cancel, error, and truncation paths). + * Canonical selection of a child's final assistant output. Backend run results + * and `subagent/end.lastAssistantMessage` apply the same rule: select the last + * non-empty assistant message. An empty-content message records usage only + * when the loop appends it after a max-tokens step with no executable blocks, + * so it does not replace earlier output. If no non-empty message exists, + * select the accumulated assistant text. Selection is independent of the + * run's stop reason. * * @module @deepseek-ai/dsh-subagent/assistant-output */ @@ -35,7 +34,7 @@ export class AssistantOutputFold { const content = event.data.message.content if (content.length > 0) this.message = content } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { - this.partial.push(event.data.chunk.text) + this.pushText(event.data.chunk.text) } } @@ -44,7 +43,7 @@ export class AssistantOutputFold { * @param text - the next streamed text piece (an empty piece is a no-op). */ pushText(text: string): void { - this.partial.push(text) + if (text.length > 0) this.partial.push(text) } /** diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index df050f5538..b0e66ea695 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -129,8 +129,7 @@ export function observeRun( emit('subagent/end', { ...identity, stopReason: result.stopReason, - // One encoding for "no output" across both lifecycle shapes: the - // field is absent, matching the continuable epoch edge. + // Omit the field when no output exists, matching continuable epochs. ...result.output.length === 0 ? {} : { lastAssistantMessage: result.output }, }, parent) }, diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 7004ad4ffb..63a890176e 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -218,10 +218,10 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM */ export interface SubagentResult { /** - * The child's final assistant output: the content of the last NON-EMPTY - * assistant message (an empty-content message hosts only usage and is - * skipped), else the text streamed before the turn was cut short, or `[]` - * when the child produced none. + * The child's final assistant output is the content of its last non-empty + * assistant message. Empty-content messages, including usage-only messages, + * are skipped. Without a non-empty message, the output is its accumulated + * assistant text stream, or `[]` when the child produced neither. */ readonly output: ContentBlock[] /** diff --git a/packages/subagent/subagent/tests/assistant-output.spec.ts b/packages/subagent/subagent/tests/assistant-output.spec.ts index 2205431aae..3d510a94e8 100644 --- a/packages/subagent/subagent/tests/assistant-output.spec.ts +++ b/packages/subagent/subagent/tests/assistant-output.spec.ts @@ -15,6 +15,22 @@ function reasoningDelta(text: string): SessionEvent { return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent } +function toolResult(text: string): SessionEvent { + return { + type: 'tool/result', + data: { + message: { + content: [{ + type: 'tool-result', + toolCallId: 'call-1', + content: [{ type: 'text', text }], + isError: false, + }], + }, + }, + } as SessionEvent +} + describe('finalAssistantOutput', () => { it('selects the last non-empty message past a later empty usage-only message', () => { const events = [ @@ -25,19 +41,30 @@ describe('finalAssistantOutput', () => { expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }]) }) - it('prefers a non-empty message over the streamed text', () => { + it('prefers a non-empty message over text streamed before and after it', () => { const events = [ - textDelta('streamed '), - textDelta('text'), + textDelta('earlier partial'), message([{ type: 'text', text: 'complete answer' }]), + textDelta('later partial'), + message([]), ] expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }]) }) - it('falls back to accumulated text deltas when no non-empty message exists', () => { + it('treats textless assistant content as a non-empty message', () => { + const content: ContentBlock[] = [{ type: 'reasoning', text: 'complete reasoning' }] + expect(finalAssistantOutput([ + textDelta('streamed text'), + message(content), + textDelta('later partial'), + ])).toEqual(content) + }) + + it('falls back to text deltas without including reasoning or tool-result content', () => { const events = [ reasoningDelta('thinking'), textDelta('partial '), + toolResult('tool output'), textDelta('answer'), message([]), ] diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index b60ae57f99..e15b98ca5a 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -1201,10 +1201,9 @@ describe('continuable review regressions', () => { }) it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => { - // Step 1 streams text plus a tool call; step 2 hits max-tokens having - // assembled only a tool-call block, so the loop appends an EMPTY - // assistant/message to host usage. The terminal edge reports the epoch's - // real answer text, not the internal usage marker. + // A tool-only max-tokens step records an empty assistant/message for + // usage. The terminal event retains the previous assistant content, + // including its tool call but not the intervening tool result. const { ctx, parent } = await setup([ toolCallResponse('t1', 'noop', {}, 'partial one'), [ diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index b46f2489fa..af009d1ca3 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -264,8 +264,8 @@ describe('SubagentService', () => { stopReason: 'completed', })) - // "No output" has ONE encoding on the end edge: the field is absent, - // never an empty array, matching the continuable epoch edge. + // The lifecycle event omits lastAssistantMessage when output is empty, + // matching the continuable epoch event. const silent = new StubProvider('silent', NO_CAPS, { output: [], stopReason: 'completed' }) subagents.registerProvider(silent) const silentRun = await subagents.start('silent', baseRequest()) From eb298a439fa6e2ad1bf0b5c64b6ed98113309974 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 22:54:19 +0800 Subject: [PATCH 23/77] test(web): count the preset icon and keep the access chevron aria-hidden --- .../src/client/skeleton/PermissionSelect.module.css | 3 +++ .../src/client/skeleton/PermissionSelect.tsx | 8 ++++---- packages/client/ui-primitives/src/icons/index.tsx | 2 +- packages/client/ui-primitives/tests/icons.spec.tsx | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index 60aceaa120..22f64d6e61 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -51,6 +51,9 @@ } .chevron { + /* inline-flex, not inline: an inline seat reserves baseline descent under + the svg and floats the glyph off-center in the 28px trigger. */ + display: inline-flex; flex: 0 0 auto; color: var(--dsw-alias-label-caption); transition: transform 120ms ease; diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 47bd4e274d..73f4080c1c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -147,10 +147,10 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect {permissionGlyph(currentValue)} )} {current === undefined ? displayName(currentValue) : optionLabel(current)} - {/* Same glyph + open rotation as the sibling ModelSelect trigger; - class on the svg itself — an inline wrapper span leaves - baseline descent under the icon and floats it off-center. */} - + {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} + + + } /> diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 354adc4454..972e0ec14d 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -349,7 +349,7 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => ( ) -/** ic_ds_agent_preset_outline_16. The three node interiors knock out to transparency via mask so the glyph sits on any background. */ +/** ic_ds_agent_preset_outline_16 (figma extract): node interiors knock out to transparency via mask, so the glyph sits on any fill. */ export const IconAgentPresetOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index fd15671b73..f6560a4cc1 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 18 figma extracts + three product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(67) + it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(68) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { From 00708b950b5d562515377453ce856caa53f44e33 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:13:32 +0800 Subject: [PATCH 24/77] feat: direct issue status from PR review events --- .agents/notes/archived/manifest.json | 6 + ...-04-forward-only-pr-issue-status.i18n.yaml | 4 +- ...2026-08-04-forward-only-pr-issue-status.md | 1 + ...6-08-04-forward-only-pr-issue-status.zh.md | 1 + ...-driven-issue-lifecycle-triggers.i18n.yaml | 4 +- ...-review-driven-issue-lifecycle-triggers.md | 1 + ...view-driven-issue-lifecycle-triggers.zh.md | 1 + ...-event-directed-pr-review-status.i18n.yaml | 6 + ...6-08-10-event-directed-pr-review-status.md | 41 ++++++ ...8-10-event-directed-pr-review-status.zh.md | 41 ++++++ .github/issue-management/config.json | 1 + .github/issue-management/policy.mjs | 139 ++++++++++++++---- .github/issue-management/policy.test.mjs | 88 ++++++++--- .github/workflows/issue-lifecycle.yml | 1 + lefthook.yml | 4 + scripts/ci-workflow.spec.ts | 31 +++- 16 files changed, 313 insertions(+), 57 deletions(-) rename .agents/notes/{implemented => archived}/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml (68%) rename .agents/notes/{implemented => archived}/process/2026-08-04-forward-only-pr-issue-status.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-04-forward-only-pr-issue-status.zh.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml (66%) rename .agents/notes/{implemented => archived}/process/2026-08-08-review-driven-issue-lifecycle-triggers.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md (99%) create mode 100644 .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md create mode 100644 .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 638d87373d..f1613ab0d6 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -301,6 +301,12 @@ "process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5", "process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936", "process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e", + "process/2026-08-04-forward-only-pr-issue-status.i18n.yaml": "sha256:af23e203a66a95674154899410e2f420d1d0685dbf856c24cfccdaa547a17925", + "process/2026-08-04-forward-only-pr-issue-status.md": "sha256:2d31077da47d95ab3ddf64d5efc6b1b8fb7c7709d39aca4a825ef9e9d382d501", + "process/2026-08-04-forward-only-pr-issue-status.zh.md": "sha256:b61f865b7a8a0ac901250a3edbb92ea73177067c4c25448c7088925c2caeccd7", + "process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml": "sha256:4c28c59d3fc323e7cd01eff31f1fe759834719c5bede1e82b39f868970bf856d", + "process/2026-08-08-review-driven-issue-lifecycle-triggers.md": "sha256:1b0514de5d030170e91e12e4d6ba788a9247f840e82700faa385a1c0c76ab857", + "process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md": "sha256:028d78d61f603d8bac64c4cce20b393a78f8e029d3bb4976e79a47ecaefa6032", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml similarity index 68% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml index b8e885d109..a7df92883f 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.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-08-04-forward-only-pr-issue-status.md -2026-08-04-forward-only-pr-issue-status.md: dd567707bc7fccd0a631943ab3ffd2838a7f2f76 -2026-08-04-forward-only-pr-issue-status.zh.md: f7fee58d6afb812f97569ae4d86c3d6504f35752 +2026-08-04-forward-only-pr-issue-status.md: 56004a39ce52c77429574f481d9945cdc4936d30 +2026-08-04-forward-only-pr-issue-status.zh.md: ee85319842d3245bdfab9668de0a42ab29597fac diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md index dd567707bc..56004a39ce 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md @@ -1,6 +1,7 @@ # Agent Note: Forward-only PR-to-Issue status projection Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-04-forward-only-pr-issue-status.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md index f7fee58d6a..ee85319842 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md @@ -1,6 +1,7 @@ # Agent Note: PR 到 Issue 的状态仅向前投射 Status: implemented +Archived: 2026-08-10 [English](2026-08-04-forward-only-pr-issue-status.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml similarity index 66% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml index a82d54640c..4c3a8c8db5 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.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-08-08-review-driven-issue-lifecycle-triggers.md -2026-08-08-review-driven-issue-lifecycle-triggers.md: 8a2d48ee23da4c20bb832ae0109e2ea9912dac83 -2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 004739ff471815b0fe12e111eba0ec7aaaef9507 +2026-08-08-review-driven-issue-lifecycle-triggers.md: 444927968912d93f473e27ae8576e8371b9c287c +2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 6e00e2a936b6421824743e779756011fcd4a1c9e diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md index 8a2d48ee23..4449279689 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md @@ -1,6 +1,7 @@ # Agent Note: Review-driven Issue lifecycle triggers Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-08-review-driven-issue-lifecycle-triggers.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md index 004739ff47..6e00e2a936 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md @@ -1,6 +1,7 @@ # Agent Note: 由评审驱动的 Issue 生命周期触发器 Status: implemented +Archived: 2026-08-10 [English](2026-08-08-review-driven-issue-lifecycle-triggers.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml new file mode 100644 index 0000000000..08607d5317 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.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/process/2026-08-10-event-directed-pr-review-status.md +2026-08-10-event-directed-pr-review-status.md: 9db9c64fc87c1701028ae825357c3cbd7fef44d1 +2026-08-10-event-directed-pr-review-status.zh.md: 381a3f64a62930a584f48cfbc3571679bbcbcef7 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md new file mode 100644 index 0000000000..9db9c64fc8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md @@ -0,0 +1,41 @@ +# Agent Note: Event-directed PR review status commands + +Status: implemented + +English | [中文](2026-08-10-event-directed-pr-review-status.zh.md) + +## Problem + +The Issue Project status records who owns the next step of resolving work. Aggregate pull-request review state answers whether GitHub considers the pull request mergeable, but it cannot represent that handoff: an earlier `CHANGES_REQUESTED` review can remain effective after the author fixes the code and requests review again. + +A monotonic projection also cannot return an automation-owned Issue from `In review` to `In progress` when a reviewer requests changes. Reconstructing review rounds or reviewer blockers would add state that the required two-event contract does not need. + +## Decision + +The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions skip their lifecycle job before it creates a Project token, while dismissed reviews are not subscribed. + +Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status. + +The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. + +[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review. + +## Verification + +[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the changes-requested job condition, and the separate `ready_for_review` policy trigger. + +## Alternatives considered + +**Derive status from `reviewDecision` or a reconstructed review round.** GitHub's aggregate can remain `CHANGES_REQUESTED` after a repeated review request, while a round reducer introduces reviewer and ordering semantics beyond the two explicit handoffs. + +**Keep the forward-only projection.** Monotonic advancement protects later statuses, but it leaves an Issue in `In review` while the author is implementing requested changes. + +**Apply every review command unconditionally.** This is the smallest event handler, but it lets automation overwrite a human-owned Project status. The latest target-Project status actor therefore guards the only backward transition. + +**Restore `ready_for_review` or add a debounce queue.** Ready status carries neither review handoff, while another queue adds latency and control-plane state without changing either command. + +## Consequences + +A repeated review request moves an automation-managed resolving Issue to `In review` even while GitHub still reports an older blocking review. A later changes-requested review returns it to `In progress`; approval, comments, dismissal, pushes, and reviewer removal leave the most recent command's status unchanged. + +The projection remains event-driven and does not repair an event that never runs. Replaying an old workflow run can replay its old command, and ProjectV2 still provides no atomic compare-and-swap between the latest-state read and mutation. Per-pull-request workflow concurrency and the human-ownership guard reduce these races without introducing durable lifecycle state. diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md new file mode 100644 index 0000000000..381a3f64a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 由事件直接指定的 PR 评审状态命令 + +Status: implemented + +[English](2026-08-10-event-directed-pr-review-status.md) | 中文 + +## 问题 + +Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责。PR(Pull Request)的汇总评审状态可以回答 GitHub 是否认为该 PR 可合并,却无法表示这次交接:作者修复代码并重新请求评审后,先前的 `CHANGES_REQUESTED` 评审仍可能继续生效。 + +单调投影也无法在评审人提出修改要求时,将由自动化管理的 Issue 从 `In review` 退回 `In progress`。重建评审轮次或评审人阻塞项会引入既定双事件约定并不需要的状态。 + +## 决策 + +Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review`。`pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state` 为 `changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流会在生命周期作业创建 Project token 前跳过该作业;dismissed 评审则不在订阅范围内。 + +工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。 + +处理器仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。 + +[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。 + +## 验证 + +[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、请求修改作业的条件,以及独立的 `ready_for_review` 策略触发器。 + +## 考虑过的替代方案 + +**根据 `reviewDecision` 或重建的评审轮次派生状态。** GitHub 的汇总状态在重复请求评审后仍可能保持为 `CHANGES_REQUESTED`,而轮次归约器会引入超出两个显式交接动作所需范围的评审人语义和顺序语义。 + +**保留只向前推进的投影。** 单调推进可保护较后的状态不被回退,但作者正在按要求修改代码时,Issue 会一直停留在 `In review`。 + +**无条件应用每条评审命令。** 这是最精简的事件处理器,但会让自动化覆盖由人工管理的 Project 状态。因此,处理器通过目标 Project 最新状态事件的执行主体保护唯一允许的回退转换。 + +**恢复 `ready_for_review` 或添加防抖队列。** Ready 状态并不表示两种评审交接中的任何一种;新增队列只会增加延迟和控制平面状态,不会改变任一命令。 + +## 后果 + +即使 GitHub 仍报告一个较早的阻塞性评审,重复请求评审也会将正由当前 PR 解决且由自动化管理的 Issue 推进至 `In review`。后续提出修改要求的评审会将其退回 `In progress`;批准、评论、撤销评审、推送和移除评审人都不会改变最近一条命令设定的状态。 + +投影仍由事件驱动;如果某个事件从未触发工作流运行,投影不会自行修复。回放旧的工作流运行可能会再次执行其中的旧命令;ProjectV2 仍不提供在读取最新状态与执行变更之间进行原子比较并交换(compare-and-swap)的能力。以单个 PR 为粒度的工作流并发控制和人工状态所有权保护机制可减少这些竞态,而无需引入持久化生命周期状态。 diff --git a/.github/issue-management/config.json b/.github/issue-management/config.json index 41019f0aa2..5dc925f245 100644 --- a/.github/issue-management/config.json +++ b/.github/issue-management/config.json @@ -3,6 +3,7 @@ "repository": "deepseek-harness", "projectNumber": 1, "projectTitle": "DSH Issue Management", + "lifecycleActor": "dsh-issue-management", "priorityField": "Priority", "allowUnassignedOwner": true, "statuses": [ diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 2125ba2f36..24a82cf15f 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -37,10 +37,21 @@ const LEGACY_LABELS = new Set([ ]) const TERMINAL_STATUSES = new Set(['Done', 'No action']) const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status)) +const IMPLEMENTATION_PULL_REQUEST_ACTIONS = new Set([ + 'opened', + 'edited', + 'synchronize', + 'reopened', + 'labeled', + 'unlabeled', +]) for (const status of ['In progress', 'In review']) { if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`) } +if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) { + throw new Error('config.lifecycleActor 未设置') +} /** * Return Markdown outside balanced details elements. @@ -159,18 +170,48 @@ export function requiresPullRequestPolicy({ } /** - * Derive a forward-only Issue status from the current PR phase. - * @param {string|null} currentStatus Current Project status. - * @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase. - * @returns {string|null} Status to write, or null when no forward transition exists. + * Translate a repository event into one resolving-Issue lifecycle command. + * @param {string} eventName GitHub event name. + * @param {{action?: string, review?: {state?: string}}} event GitHub event payload. + * @returns {'implementation'|'review-requested'|'changes-requested'|null} Lifecycle command. */ -export function nextResolvingIssueStatus(currentStatus, pull) { - const target = - !pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0) - ? 'In review' - : 'In progress' +export function resolvingIssueStatusCommand(eventName, event) { + if (eventName === 'pull_request') { + if (event.action === 'review_requested') return 'review-requested' + return IMPLEMENTATION_PULL_REQUEST_ACTIONS.has(event.action) ? 'implementation' : null + } + if ( + eventName === 'pull_request_review' && + event.action === 'submitted' && + event.review?.state?.toLowerCase() === 'changes_requested' + ) { + return 'changes-requested' + } + return null +} + +/** + * Plan one event-directed resolving-Issue status transition. + * @param {string|null} currentStatus Current Project status. + * @param {'implementation'|'review-requested'|'changes-requested'} command Lifecycle command. + * @param {string|null} currentStatusActor Actor that last set the current Project status. + * @returns {string|null} Status to write, or null when no permitted transition exists. + */ +export function nextResolvingIssueStatus(currentStatus, command, currentStatusActor = null) { + let target + if (command === 'review-requested') target = 'In review' + else if (command === 'implementation' || command === 'changes-requested') target = 'In progress' + else throw new Error(`未知 lifecycle command:${command}`) + const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus) const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target) + if ( + command === 'changes-requested' && + currentStatus === 'In review' && + currentStatusActor === config.lifecycleActor + ) { + return target + } return currentIndex >= 0 && currentIndex < targetIndex ? target : null } @@ -396,9 +437,15 @@ async function issueSnapshot(number, status = undefined) { } } -async function projectContext(number) { +async function projectContext(number, includeStatusActor = false) { const data = await graphql( - `query($organization: String!, $repository: String!, $number: Int!, $project: Int!) { + `query( + $organization: String! + $repository: String! + $number: Int! + $project: Int! + $includeStatusActor: Boolean! + ) { organization(login: $organization) { projectV2(number: $project) { id @@ -413,6 +460,16 @@ async function projectContext(number) { repository(owner: $organization, name: $repository) { issue(number: $number) { id + timelineItems(last: 100, itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT]) + @include(if: $includeStatusActor) { + nodes { + ... on ProjectV2ItemStatusChangedEvent { + actor { login } + project { id } + status + } + } + } projectItems(first: 20, includeArchived: true) { nodes { id @@ -430,6 +487,7 @@ async function projectContext(number) { repository: config.repository, number, project: config.projectNumber, + includeStatusActor, }, ) const project = data.organization?.projectV2 @@ -439,7 +497,14 @@ async function projectContext(number) { const statusField = project.fields.nodes.find((field) => field?.name === 'Status') if (!statusField) throw new Error('Project 缺少 Status 字段') const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id) - return { project, issue, statusField, item } + const latestStatusEvent = issue.timelineItems?.nodes + ?.filter((event) => event?.project?.id === project.id) + .at(-1) + const statusActor = + latestStatusEvent?.status === item?.fieldValueByName?.name + ? (latestStatusEvent.actor?.login ?? null) + : null + return { project, issue, statusField, item, statusActor } } async function projectStatus(number) { @@ -530,12 +595,7 @@ async function auditIssue(number, extraErrors = [], status = undefined) { return errors } -async function pullRequestSnapshot(number) { - const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) - const [reviewRequests, reviews] = await Promise.all([ - api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`), - api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`), - ]) +async function resolvingReferencesSnapshot(number, pull) { const references = parseReferences({ body: pull.body ?? '', repository: `${config.organization}/${config.repository}`, @@ -547,20 +607,41 @@ async function pullRequestSnapshot(number) { } return { number, - isDraft: pull.draft, - authorType: pull.user?.type ?? 'User', - reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, - reviewCount: reviews.length, - labels: pull.labels.map((label) => label.name), references: retainIssueReferences(references, issues), issues, } } -async function advanceResolvingIssues(pull) { +async function pullRequestSnapshot(number) { + const [pull, reviewRequests, reviews] = await Promise.all([ + api(`/repos/${config.organization}/${config.repository}/pulls/${number}`), + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`), + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`), + ]) + const resolving = await resolvingReferencesSnapshot(number, pull) + return { + ...resolving, + isDraft: pull.draft, + authorType: pull.user?.type ?? 'User', + reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, + reviewCount: reviews.length, + labels: pull.labels.map((label) => label.name), + } +} + +async function lifecyclePullRequestSnapshot(number) { + const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) + return resolvingReferencesSnapshot(number, pull) +} + +async function transitionResolvingIssues(pull, command) { for (const number of pull.references.resolving) { - const context = await projectContext(number) - const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull) + const context = await projectContext(number, command === 'changes-requested') + const target = nextResolvingIssueStatus( + context.item?.fieldValueByName?.name ?? null, + command, + context.statusActor, + ) if (!target) continue // TODO: Replace this latest-state guard with per-Issue serialization or a // conditional ProjectV2 update; GraphQL currently has no compare-and-swap. @@ -598,8 +679,10 @@ async function runLifecycle(eventName, event) { } if (eventName === 'pull_request' || eventName === 'pull_request_review') { - const pull = await pullRequestSnapshot(event.pull_request.number) - await advanceResolvingIssues(pull) + const command = resolvingIssueStatusCommand(eventName, event) + if (!command) return + const pull = await lifecyclePullRequestSnapshot(event.pull_request.number) + await transitionResolvingIssues(pull, command) } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 8a9b0f91e6..c03a7c3513 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -6,6 +6,7 @@ import { nextResolvingIssueStatus, parseReferences, retainIssueReferences, + resolvingIssueStatusCommand, requiresPullRequestPolicy, validateBody, validateIssue, @@ -243,32 +244,73 @@ test('requires policy only after a human PR enters review', () => { ) }) -test('advances resolving Issues to the live PR phase', () => { - const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 } - const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } - const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 } - const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } - - for (const status of ['Inbox', 'Backlog', 'Ready']) { - assert.equal(nextResolvingIssueStatus(status, draft), 'In progress') - assert.equal(nextResolvingIssueStatus(status, open), 'In progress') - assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review') - assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review') +test('maps only explicit review handoffs to review status commands', () => { + assert.equal( + resolvingIssueStatusCommand('pull_request', { + action: 'review_requested', + }), + 'review-requested', + ) + assert.equal( + resolvingIssueStatusCommand('pull_request_review', { + action: 'submitted', + review: { state: 'changes_requested' }, + }), + 'changes-requested', + ) + for (const state of ['approved', 'commented']) { + assert.equal( + resolvingIssueStatusCommand('pull_request_review', { + action: 'submitted', + review: { state }, + }), + null, + ) } - assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review') - assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review') + assert.equal( + resolvingIssueStatusCommand('pull_request_review', { + action: 'dismissed', + review: { state: 'changes_requested' }, + }), + null, + ) }) -test('never regresses or reopens a resolving Issue', () => { - const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } - const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } +test('keeps ordinary pull request events as forward-only implementation signals', () => { + for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) { + assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation') + } + assert.equal( + resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }), + null, + ) +}) - assert.equal(nextResolvingIssueStatus('In progress', implementation), null) - assert.equal(nextResolvingIssueStatus('In review', implementation), null) - assert.equal(nextResolvingIssueStatus('In review', review), null) - assert.equal(nextResolvingIssueStatus('Done', review), null) - assert.equal(nextResolvingIssueStatus('No action', review), null) - assert.equal(nextResolvingIssueStatus(null, review), null) +test('toggles automation-owned work on request changes and repeated review request', () => { + for (const status of ['Inbox', 'Backlog', 'Ready']) { + assert.equal(nextResolvingIssueStatus(status, 'implementation'), 'In progress') + assert.equal(nextResolvingIssueStatus(status, 'review-requested'), 'In review') + assert.equal(nextResolvingIssueStatus(status, 'changes-requested'), 'In progress') + } + let status = nextResolvingIssueStatus( + 'In review', + 'changes-requested', + 'dsh-issue-management', + ) + assert.equal(status, 'In progress') + status = nextResolvingIssueStatus(status, 'review-requested') + assert.equal(status, 'In review') +}) + +test('preserves human review status and terminal Issues', () => { + assert.equal(nextResolvingIssueStatus('In progress', 'implementation'), null) + assert.equal(nextResolvingIssueStatus('In review', 'implementation'), null) + assert.equal(nextResolvingIssueStatus('In review', 'review-requested'), null) + assert.equal(nextResolvingIssueStatus('In review', 'changes-requested', 'tianyicui'), null) + assert.equal(nextResolvingIssueStatus('In review', 'changes-requested'), null) + assert.equal(nextResolvingIssueStatus('Done', 'review-requested'), null) + assert.equal(nextResolvingIssueStatus('No action', 'changes-requested'), null) + assert.equal(nextResolvingIssueStatus(null, 'review-requested'), null) }) test('keeps lifecycle projection independent of PR metadata enforcement', () => { @@ -283,7 +325,7 @@ test('keeps lifecycle projection independent of PR metadata enforcement', () => } assert.ok(validatePullRequest(pull).length > 0) - assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review') + assert.equal(nextResolvingIssueStatus('Inbox', 'review-requested'), 'In review') }) test('exempts Draft, Bot, and App PRs', () => { diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 7a25b5223d..300d8e4bfa 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -36,6 +36,7 @@ concurrency: jobs: lifecycle: name: Issue lifecycle + if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }} runs-on: ubuntu-latest steps: - name: Check out trusted policy diff --git a/lefthook.yml b/lefthook.yml index 0ea7f4e537..7ed1fcb886 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -6,6 +6,8 @@ pre-commit: jobs: - name: translation pairing (staged records) glob: '*.i18n.yaml' + exclude: + - '.agents/notes/archived/**' run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} - name: lint (staged) @@ -35,6 +37,8 @@ pre-merge-commit: jobs: - name: translation pairing (staged records) glob: '*.i18n.yaml' + exclude: + - '.agents/notes/archived/**' run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} pre-push: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 2269c7cbee..a67dc0818a 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -113,20 +113,40 @@ describe('E2B e2e workflow', () => { }) describe('Issue lifecycle workflow', () => { - it('uses review signals instead of rerunning when a draft becomes ready', () => { + it('uses explicit review handoff events without rerunning when a draft becomes ready', () => { const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request') const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review') + const lifecycleJob = workflowJob(lifecycle, 'lifecycle') const policy = loadWorkflow('.github/workflows/issue-policy.yml') const policyPullRequest = workflowEvent(policy, 'pull_request') expect(lifecyclePullRequest.types).not.toContain('ready_for_review') expect(lifecyclePullRequest.types).toContain('review_requested') - expect(lifecycleReview.types).toContain('submitted') + expect(lifecycleReview.types).toEqual(['submitted']) + expect(lifecycleJob.if).toBe( + "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}", + ) expect(policyPullRequest.types).toContain('ready_for_review') }) }) +describe('Git hooks', () => { + it('leaves frozen Agent Note sidecars to the archive verifier', () => { + const lefthook = loadWorkflow('lefthook.yml') + + for (const hookName of ['pre-commit', 'pre-merge-commit']) { + const hook = lefthook[hookName] + if (!isRecord(hook) || !Array.isArray(hook.jobs)) { + throw new TypeError(`lefthook must define ${hookName} jobs`) + } + const pairing = hook.jobs.find(job => isRecord(job) && job.name === 'translation pairing (staged records)') + + expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] }) + } + }) +}) + function loadWorkflow(path: string): Record { const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8')) if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`) @@ -140,6 +160,13 @@ function workflowEvent(workflow: Record, event: string): Record return workflow.on[event] } +function workflowJob(workflow: Record, job: string): Record { + if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs[job])) { + throw new TypeError(`workflow must define the ${job} job`) + } + return workflow.jobs[job] +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } From 9e8cd1acfb94613f121cfd105fd36a26e6a0d406 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 23:14:27 +0800 Subject: [PATCH 25/77] test(web): re-record the preset section golden and borderless menu inset --- .../tests/snapshots/agent-preset-authoring/section.expected.md | 1 + apps/web/tests/subagent-conversation.e2e.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index dcbe72641c..9f87471f02 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -58,6 +58,7 @@ - 'button "复制: 创造模式"': - img - text: 复制 + - heading "自定义" [level=3] - button "用「创造模式」创作自定义预设": - img - text: 用「创造模式」创作自定义预设 diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index fa33e5cd3d..6756b093d2 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -395,7 +395,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect([ Math.round(clickAreaBox!.x - treeBox!.x), Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), - ]).toEqual([5, 5]) + // Menu padding alone insets the rows now that the border is gone. + ]).toEqual([4, 4]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), From a8fae974c2be649633990080f7060421dfeb0213 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 23:19:23 +0800 Subject: [PATCH 26/77] test(web): the custom group heading outlives its last preset --- apps/web/tests/agent-preset-authoring.e2e.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index e8ff5aa538..6a13b791c5 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -176,8 +176,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0) expect(existsSync(join(userRoot, 'my-agent'))).toBe(false) - // Custom group gone with its only member; the shipped set stands. - expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0) + // The custom group outlives its only member: the heading stays with the + // creator entry so the place to author a preset never disappears. + expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1) expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) }, 60_000) From 3716459223f7f23a78639b35da608141fb1f95b9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:35:05 +0800 Subject: [PATCH 27/77] fix(ci): narrow issue lifecycle review events --- .github/workflows/issue-lifecycle.yml | 2 +- scripts/ci-workflow.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 300d8e4bfa..e324cfefc2 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -36,7 +36,7 @@ concurrency: jobs: lifecycle: name: Issue lifecycle - if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }} + if: ${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }} runs-on: ubuntu-latest steps: - name: Check out trusted policy diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index a67dc0818a..db5ea9a0fa 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -125,7 +125,7 @@ describe('Issue lifecycle workflow', () => { expect(lifecyclePullRequest.types).toContain('review_requested') expect(lifecycleReview.types).toEqual(['submitted']) expect(lifecycleJob.if).toBe( - "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}", + "${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}", ) expect(policyPullRequest.types).toContain('ready_for_review') }) From e9a3a388736700f19e3a4ec9d21b53de58aad47f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:55:27 +0800 Subject: [PATCH 28/77] fix(ci): type workflow fixture search safely --- scripts/ci-workflow.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index db5ea9a0fa..2e4df208f4 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -140,7 +140,9 @@ describe('Git hooks', () => { if (!isRecord(hook) || !Array.isArray(hook.jobs)) { throw new TypeError(`lefthook must define ${hookName} jobs`) } - const pairing = hook.jobs.find(job => isRecord(job) && job.name === 'translation pairing (staged records)') + const pairing: unknown = hook.jobs.find( + (job: unknown) => isRecord(job) && job.name === 'translation pairing (staged records)', + ) expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] }) } From 6147f0238609e0b9106ac508564c6c6ac1889b57 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:57:44 +0800 Subject: [PATCH 29/77] fix(mcp-client): await failed generation shutdown The MCP SDK starts a fire-and-forget close when initialization fails. Its stdio transport clears its process field before that close finishes, so our second Client.close() could return immediately and the reconnect timer could launch a replacement while the original child was still alive. Track the transport onclose signal for every client generation and gate failed-attempt backoff on both Client.close() settlement and that signal. Use the same barrier during plugin disposal. If the SDK's bounded stdio termination window expires without onclose, fail closed and report incomplete shutdown instead of risking overlapping server processes. Regression coverage models the SDK's early-returning second close, delayed and missing close signals, pending-connect disposal, close rejection, and the terminal timeout path. The reconnect Agent Note and Chinese counterpart now record the quiescence contract. --- ...-08-06-mcp-client-auto-reconnect.i18n.yaml | 4 +- .../2026-08-06-mcp-client-auto-reconnect.md | 4 +- ...2026-08-06-mcp-client-auto-reconnect.zh.md | 4 +- packages/mcp/mcp-client/src/connection.ts | 60 ++++++++++++- packages/mcp/mcp-client/tests/apply.spec.ts | 10 ++- .../mcp/mcp-client/tests/reconnect.spec.ts | 84 ++++++++++++++++++- 6 files changed, 153 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml index d9ec007b53..e0aac526cc 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.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-06-mcp-client-auto-reconnect.md -2026-08-06-mcp-client-auto-reconnect.md: b187f5de71a10ca3121d817f383a98669246c81f -2026-08-06-mcp-client-auto-reconnect.zh.md: a4ec6897c34f2142ceb95abecd13d189c4a95624 +2026-08-06-mcp-client-auto-reconnect.md: e371fa13dc7bc5330f658f4d6e864969baf93cf0 +2026-08-06-mcp-client-auto-reconnect.zh.md: eb6e67dabd7c6115b9ecb507029ffa7d45338466 diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md index b187f5de71..e371fa13dc 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md @@ -14,7 +14,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. **Trigger.** The supervisor arms `client.onclose` per generation. The SDK fires it when the stdio child exits, so a crash is observed without polling. `StreamableHTTPClientTransport` fires `onclose` only for deliberate closes — it owns its internal SSE-stream recovery and surfaces request failures per call — so HTTP servers are effectively outside supervisor restarts; the package README records that limitation. -**Generations without interleaving.** Each attempt builds a fresh transport and `Client` (the SDK binds a Protocol to one transport for life). One per-supervisor queue serializes every `syncTools` call — initial syncs and `list_changed` re-syncs across all generations — and an `isCurrent` fence makes stale generations inert, so no two syncs can interleave the dispose-previous/register-next swap (which would double-dispose one generation and leak another). The queue also closes a pre-existing race where two rapid `list_changed` notifications re-synced concurrently. Failure signals are idempotent per generation: a connect rejection racing its own transport close schedules exactly one retry. +**Generations without interleaving.** Each attempt builds a fresh transport and `Client` (the SDK binds a Protocol to one transport for life). One per-supervisor queue serializes every `syncTools` call — initial syncs and `list_changed` re-syncs across all generations — and an `isCurrent` fence makes stale generations inert, so no two syncs can interleave the dispose-previous/register-next swap (which would double-dispose one generation and leak another). The queue also closes a pre-existing race where two rapid `list_changed` notifications re-synced concurrently. Failure signals are idempotent per generation: a connect rejection racing its own transport close schedules exactly one retry. A failed attempt cannot enter backoff until both `Client.close()` settles and the transport reports `onclose`, which for stdio proves the child exited; a missing close signal stops reconnection after the SDK's bounded termination window instead of allowing two server processes to overlap. Disposal uses the same bounded close-signal barrier and reports an incomplete shutdown without ever restarting. **Bounded backoff with an outage budget.** Delays double from `initialDelayMs` up to `maxDelayMs`. One outage shares `maxAttempts` consecutive failed attempts; exhaustion unregisters the server's tools, logs at error level, and stops until disposal or reload. A connection that survives past the stability window — `maxDelayMs`, derived rather than a fifth tunable, as the longest configured backoff spacing — resets the budget, so an occasionally-crashing server recovers indefinitely while a crash loop whose connects briefly succeed cannot launder its budget into a restart storm. @@ -38,7 +38,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. ## Testing -Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps the v1 behavior, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent. +Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, failed initialization waits for the old generation's close signal and fails closed when that signal never arrives, disposal waits for the same signal with a bounded incomplete-shutdown path, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps the v1 behavior, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md index a4ec6897c3..eb6e67dabd 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md @@ -14,7 +14,7 @@ Status: implemented **触发条件。** 监督器在每一代上挂载 `client.onclose`。SDK 在 stdio 子进程退出时触发该回调,因此崩溃无需轮询即可感知。`StreamableHTTPClientTransport` 仅在主动关闭时触发 `onclose`——它内部拥有自己的 SSE(Server-Sent Events)流恢复机制,并将请求失败以逐调用方式暴露——因此 HTTP 服务器实际上不在监督器的重启范围内;包 README 记录了该限制。 -**代隔离,无交错。** 每次尝试构建一个全新的 transport 和 `Client`(SDK 将一个 Protocol 绑定到一个 transport 上终身使用)。每个监督器内部有一个队列将所有 `syncTools` 调用串行化——跨所有代的初始同步和 `list_changed` 再同步——`isCurrent` 栅栏使过时的代变为惰性,从而确保不会有两次同步交错执行 dispose 上一代/注册下一代的切换(否则会对同一代执行两次 dispose 并泄漏另一代)。该队列还消除了一个先前存在的竞态:两次快速的 `list_changed` 通知同时触发重新同步。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。 +**代隔离,无交错。** 每次尝试构建一个全新的 transport 和 `Client`(SDK 将一个 Protocol 绑定到一个 transport 上终身使用)。每个监督器内部有一个队列将所有 `syncTools` 调用串行化——跨所有代的初始同步和 `list_changed` 再同步——`isCurrent` 栅栏使过时的代变为惰性,从而确保不会有两次同步交错执行 dispose 上一代/注册下一代的切换(否则会对同一代执行两次 dispose 并泄漏另一代)。该队列还消除了一个先前存在的竞态:两次快速的 `list_changed` 通知同时触发重新同步。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。失败尝试只有在 `Client.close()` 结算且 transport 报告 `onclose` 后才能进入退避;对 stdio 而言,`onclose` 证明子进程已退出;若关闭信号始终未到,则在 SDK 的有界终止窗口结束后停止重连,而不是允许两个服务器进程重叠运行。dispose 使用同一个有界关闭信号屏障;若关停未完成则予以报告,且绝不重启。 **有界退避与故障预算。** 延迟从 `initialDelayMs` 起逐次翻倍,上限为 `maxDelayMs`。一次故障期间共享 `maxAttempts` 次连续失败尝试的预算;耗尽后注销该服务器的工具、以 error 级别记录日志并停止,直到 dispose 或重新加载。连接在存活超过稳定窗口——即 `maxDelayMs`,作为最长退避间隔从配置推导得出而非作为第五个独立调参项——之后重置预算;因此偶尔崩溃的服务器可无限恢复,而连接短暂成功后立即再次崩溃的循环无法将其预算洗白为重启风暴。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 +单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 ## 后果 diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts index 4006594111..2760324a20 100644 --- a/packages/mcp/mcp-client/src/connection.ts +++ b/packages/mcp/mcp-client/src/connection.ts @@ -44,6 +44,11 @@ export const RECONNECT_DEFAULTS: Required = Object.freeze({ maxAttempts: 10, }) +// The SDK's stdio transport owns two two-second termination grace periods. +// Keep one additional second for the process-close event that proves the old +// generation is gone; timing out fails closed instead of overlapping children. +const GENERATION_CLOSE_TIMEOUT_MS = 5_000 + /** Fully resolved reconnect policy captured at plugin load. */ export type ResolvedReconnectPolicy = Readonly> @@ -133,6 +138,8 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe let disposed = false /** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */ let client: Client | undefined + /** Close signal paired with {@link client}; captured by dispose before current ownership is cleared. */ + let clientClosed: Promise | undefined /** Live tool registrations owned by this server; only {@link enqueueSync} and dispose swap it. */ let disposers: ToolDisposers = new Map() let reconnectTimer: NodeJS.Timeout | undefined @@ -169,9 +176,22 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe function generationDown(generation: Client): void { if (!isCurrent(generation)) return client = undefined + clientClosed = undefined scheduleReconnect() } + /** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */ + function waitForClose(closed: Promise): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { resolve(false) }, GENERATION_CLOSE_TIMEOUT_MS) + timeout.unref() + void closed.then(() => { + clearTimeout(timeout) + resolve(true) + }) + }) + } + function scheduleReconnect(): void { if (!policy.enabled) { const detail = connectedAt !== undefined @@ -216,8 +236,19 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe { name: 'dsh-mcp-client', version: '0.0.1' }, { capabilities: {} }, ) + const closed: PromiseWithResolvers = Promise.withResolvers() + let attemptSettled = false + let closeObserved = false + const hasClosed = (): boolean => closeObserved client = generation - generation.onclose = () => { generationDown(generation) } + clientClosed = closed.promise + generation.onclose = () => { + closeObserved = true + closed.resolve() + // A failed connect owns its close barrier in the catch path below. An + // established generation can transition down directly from this signal. + if (attemptSettled) generationDown(generation) + } // Registered before connect so a list change during the initial sync is // queued behind it rather than dropped. generation.setNotificationHandler( @@ -236,12 +267,32 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe ) try { await generation.connect(createTransport(config)) + if (hasClosed()) { + attemptSettled = true + generationDown(generation) + return + } await enqueueSync(generation) } catch (error) { if (firstAttemptError === undefined) firstAttemptError = error - // When the transport closed first, onclose already logged and scheduled. + // Disposal clears current ownership before it closes the generation, so + // only a live supervisor reports an attempt failure. if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`) try { await generation.close() } catch { /* transport already gone */ } + const quiesced = hasClosed() || await waitForClose(closed.promise) + attemptSettled = true + if (!isCurrent(generation)) return + if (!quiesced) { + client = undefined + clientClosed = undefined + ctx.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`) + return + } + generationDown(generation) + return + } + attemptSettled = true + if (hasClosed()) { generationDown(generation) return } @@ -277,9 +328,14 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe reconnectTimer = undefined } const current = client + const currentClosed = clientClosed client = undefined + clientClosed = undefined if (current !== undefined) { try { await current.close() } catch { /* transport already gone */ } + if (currentClosed !== undefined && !await waitForClose(currentClosed)) { + ctx.logger.error(`${label}: generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms during disposal — server shutdown may be incomplete`) + } } // Quiesce, don't just request it: the in-flight attempt enqueues its // sync before settling, so awaiting both leaves `disposers` final. diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 09d660001f..b6a9409c63 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -159,7 +159,10 @@ describe('apply (plugin lifecycle)', () => { beforeEach(async () => { vi.clearAllMocks() mockConnect.mockResolvedValue(undefined) - mockClose.mockResolvedValue(undefined) + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.resolve() + }) mockListTools.mockResolvedValue({ tools: [{ name: 'remote', description: 'A remote tool', inputSchema: { type: 'object' } }], nextCursor: undefined, @@ -338,7 +341,10 @@ describe('apply (plugin lifecycle)', () => { }) it('effect disposer handles client.close failure gracefully', async () => { - mockClose.mockRejectedValue(new Error('already closed')) + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.reject(new Error('already closed')) + }) await apply(ctx, stdioConfig) diff --git a/packages/mcp/mcp-client/tests/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts index 4abbb4cbbf..6d85e753ea 100644 --- a/packages/mcp/mcp-client/tests/reconnect.spec.ts +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -59,7 +59,7 @@ vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ // vi.mock is hoisted above static imports, so the modules under test see the // mocked SDK even through a static import. import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' -import { RECONNECT_DEFAULTS, resolveReconnectPolicy } from '@deepseek-ai/dsh-mcp-client/src/connection.ts' +import { RECONNECT_DEFAULTS, resolveReconnectPolicy, startConnection } from '@deepseek-ai/dsh-mcp-client/src/connection.ts' // ---- Helpers ---- @@ -128,7 +128,10 @@ describe('reconnect supervisor', () => { vi.clearAllMocks() instances.length = 0 mockConnect.mockResolvedValue(undefined) - mockClose.mockResolvedValue(undefined) + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.resolve() + }) mockListTools.mockResolvedValue(listing('remote')) mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }) ctx = await mountRegistry() @@ -174,7 +177,10 @@ describe('reconnect supervisor', () => { mockConnect.mockRejectedValue(new Error('server gone')) // A failing close on the failed attempt's cleanup must not break the loop. - mockClose.mockRejectedValue(new Error('already closed')) + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.reject(new Error('already closed')) + }) instances[0]!.onclose?.() await vi.waitFor(() => { @@ -189,6 +195,78 @@ describe('reconnect supervisor', () => { expect(mockConnect).toHaveBeenCalledTimes(3) }) + it('does not start a replacement until a failed generation reports that it closed', async () => { + mockConnect.mockRejectedValueOnce(new Error('initialize failed')) + // Model the SDK's fire-and-forget close after initialize fails: the + // harness's second close call returns, but the child has not exited yet. + mockClose.mockResolvedValue(undefined) + + const applying = apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 })) + await vi.waitFor(() => { expect(mockClose).toHaveBeenCalled() }) + await sleep(30) + expect(instances).toHaveLength(1) + + instances[0]!.onclose?.() + await applying + await vi.waitFor(() => { expect(instances).toHaveLength(2) }) + }) + + it('stops reconnecting when a failed generation never reports that it closed', async () => { + vi.useFakeTimers() + try { + const { errors } = captureLogs(ctx) + mockConnect.mockRejectedValue(new Error('initialize failed')) + mockClose.mockResolvedValue(undefined) + + const applying = apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 })) + await vi.advanceTimersByTimeAsync(5_000) + await applying + + expect(instances).toHaveLength(1) + expect(errors.some(line => line.includes('reconnect stopped to avoid overlapping server processes'))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses retry reporting when disposal owns a pending connect rejection', async () => { + const { warns } = captureLogs(ctx) + const gate: PromiseWithResolvers = Promise.withResolvers() + mockConnect.mockImplementation(() => gate.promise) + const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect')) + await vi.waitFor(() => { expect(instances).toHaveLength(1) }) + + const disposing = handle.dispose() + gate.reject(new Error('disposed connect')) + await disposing + await handle.ready + + expect(warns.some(line => line.includes('connection attempt failed'))).toBe(false) + expect(instances).toHaveLength(1) + }) + + it('bounds disposal while a resolving generation never reports that it closed', async () => { + vi.useFakeTimers() + try { + const { errors } = captureLogs(ctx) + const gate: PromiseWithResolvers = Promise.withResolvers() + mockConnect.mockImplementation(() => gate.promise) + mockClose.mockResolvedValue(undefined) + const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect')) + await vi.advanceTimersByTimeAsync(0) + + const disposing = handle.dispose() + await vi.advanceTimersByTimeAsync(5_000) + gate.resolve() + await disposing + + expect(mockListTools).not.toHaveBeenCalled() + expect(errors.some(line => line.includes('server shutdown may be incomplete'))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + it('dispose during the backoff wait cancels the pending reconnect', async () => { await apply(ctx, stdioConfig({ initialDelayMs: 60_000, maxDelayMs: 60_000, maxAttempts: 5 })) await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) From cac8e1c53deb24bc0123463391046b85811aef2b Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 23:59:53 +0800 Subject: [PATCH 30/77] fix(client): finish the preset intro inside one shared reveal window A fixed 60ms per-character tick made a Latin preset name run three times longer than its CJK counterpart. The stagger is now capped by a 200ms shared window (min(40, 200/(n-1))), the icon lands in 150ms with the characters starting the moment it does, and the whole timeline is pinned by component tests alongside the store acknowledgement and the empty custom group. --- ...0-creator-guidance-introduce-cue.i18n.yaml | 6 ++ ...26-08-10-creator-guidance-introduce-cue.md | 33 +++++++ ...08-10-creator-guidance-introduce-cue.zh.md | 33 +++++++ .../src/client/AgentPresetSeat.module.css | 10 ++- .../src/client/AgentPresetSeat.tsx | 32 +++++-- .../ui-agent-preset/tests/apply.spec.ts | 9 ++ .../ui-agent-preset/tests/components.spec.tsx | 88 +++++++++++++++++++ .../ui-agent-preset/tests/section.spec.tsx | 14 +++ 8 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml new file mode 100644 index 0000000000..08233cc4f0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.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-creator-guidance-introduce-cue.md +2026-08-10-creator-guidance-introduce-cue.md: 888fee7b3def585ed3098fedcb7bc6169ee26a22 +2026-08-10-creator-guidance-introduce-cue.zh.md: d80260abd1995df1f95e3f24fefcb265bda64c11 diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md new file mode 100644 index 0000000000..888fee7b3d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md @@ -0,0 +1,33 @@ +# Agent Note: Creator guidance lands as an introduce cue on the preset chip + +Status: implemented + +English | [中文](2026-08-10-creator-guidance-introduce-cue.zh.md) + +## Problem + +Authoring a preset happens inside a Creator-mode session, but the settings section gave no path into that fact. The creator entry sat outside the roster groups, the custom group vanished entirely while it had no member, and clicking the entry dropped the user onto the new-session screen with nothing marking what had changed: the staged preset chip rendered exactly as if the user had picked it by hand. Users reported not understanding that the flow had moved, or that the session they were about to start was the place where the preset gets built (#2184). + +## Decision + +The custom group stays on screen while empty — heading plus the creator entry, which lives inside the group as the standing "your preset will appear here" affordance rather than floating below the roster. + +A pick staged from another screen carries a one-shot `introduce` flag through the seat store (`stage(id, introduce)`), and the chip announces it: the preset icon eases in over 150ms, then the name's characters fade up on a stagger the moment the icon lands. The stagger is capped twice — 40ms per tick for short CJK names, and one shared 200ms reveal window (`min(40, 200/(n-1))`) so a long Latin name finishes in the same time as its CJK counterpart instead of dragging the run out per character. CSS owns the motion; the component arms it and acknowledges the cue once the run is over, so the flag never replays on a later mount. `prefers-reduced-motion` and an empty display name acknowledge immediately with no run. + +The cue is pure presentation: it is client-side seat-store state, never a session event, because the model-visible composition is already carried by the staged preset itself. + +## Alternatives considered + +**A toast or callout on the new-session screen.** It explains more, but it points at nothing — the chip is the artifact the user must find again later, and a dismissable box teaches the box, not the control. The cue puts the motion on the control itself. + +**A fixed per-character tick.** The first implementation used 60ms per character unconditionally; an English preset name took over three times as long as its four-character Chinese counterpart, reading as lag rather than emphasis. The shared reveal window makes duration a property of the cue, not of the locale. + +**Animating the pick inside the settings dialog before leaving.** The dialog closes as part of the gesture — leaving settings is how the flow says the work happens in the session — so anything played there would be cut off or would delay the navigation it exists to explain. + +## Consequences + +The intro timeline lives in two places that must agree: the component's `INTRO_TEXT_DELAY_MS` and the `.introIcon` CSS animation duration. The component's constants are the source of the character delays and the acknowledgement timeout; the CSS comment names the coupling. The seat store gains one bit of UI state (`introduce`) that every stage decides explicitly, and the section keeps rendering a group with no members — a shape the section golden and unit tests now pin. + +## Testing + +Component tests pin the capped stagger (11-character Latin name at 20ms steps, 4-character CJK name at the 40ms tick, single character with no stagger), the acknowledgement timing, and the reduced-motion and empty-name skips. `apply.spec.ts` drives the cross-screen stage end to end: the creator draft stages with the cue set, one acknowledgement clears it, and a repeat acknowledgement leaves the snapshot untouched. The `agent-preset-authoring` web e2e holds the empty custom group (heading plus creator entry) in its goldens. diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md new file mode 100644 index 0000000000..d80260abd1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 创造模式引导以介绍动效落在预设 chip 上 + +Status: implemented + +[English](2026-08-10-creator-guidance-introduce-cue.md) | 中文 + +## 问题 + +预设的创作发生在创造模式 session 内部,但设置分区没有把这条路径讲清楚。创建入口游离在名册分组之外;自定义分组在没有成员时整个消失;点击入口后用户被抛到新会话屏幕,没有任何标记说明发生了什么变化:暂存的预设 chip 渲染得和用户亲手挑选时一模一样。用户反馈看不懂流程已经移动,也不明白即将开始的 session 正是构建预设的地方(#2184)。 + +## 决定 + +自定义分组在空的时候也常驻屏幕——分组标题加创建入口,入口移入分组内部,作为"你的预设会出现在这里"的常设指引,而不是漂在名册下方。 + +从另一屏幕暂存的选择会经由 seat store 携带一次性的 `introduce` 标志(`stage(id, introduce)`),chip 据此自我介绍:预设图标在 150ms 内缓入,落定的瞬间名称逐字符错峰浮现。错峰有两重上限——短的中文名按每字符 40ms 的节拍,同时共享一个 200ms 的整体揭示窗口(`min(40, 200/(n-1))`),让长的拉丁名与中文名在相同时间内完成,而不是按字符数拖长整轮动画。动效由 CSS 负责;组件只负责触发,并在一轮结束后确认该提示,因此标志不会在后续挂载时重放。`prefers-reduced-motion` 与空显示名会立即确认、不播放动画。 + +该提示纯属呈现层:它是客户端 seat-store 状态,永远不是 session 事件,因为模型可见的组合已由暂存的预设本身承载。 + +## 曾考虑的替代方案 + +**在新会话屏幕上弹 toast 或提示框。** 它能解释更多,但什么也没指向——chip 才是用户之后必须再次找到的对象,可关闭的提示框教会的是提示框本身,不是控件。介绍动效把动作放在控件本体上。 + +**固定的每字符节拍。** 第一版实现无条件使用每字符 60ms;英文预设名的时长超过四字中文名的三倍,读起来像卡顿而非强调。共享揭示窗口让时长成为提示的属性,而不是语言的属性。 + +**离开前在设置对话框内播放选中动画。** 关闭对话框本身就是这个手势的一部分——离开设置正是流程在表达"工作发生在 session 里"——在那里播放的任何内容要么被截断,要么会拖延它本要解释的跳转。 + +## 后果 + +介绍时间线存在于两处且必须一致:组件的 `INTRO_TEXT_DELAY_MS` 与 `.introIcon` 的 CSS 动画时长。组件常量是字符延迟与确认超时的来源;CSS 注释点明了这层耦合。seat store 多出一位 UI 状态(`introduce`),每次暂存都显式决定它;分区则会渲染没有成员的分组——这一形态现由分区 golden 与单元测试钉住。 + +## 测试 + +组件测试钉住带上限的错峰(11 字符拉丁名走 20ms 步进、4 字中文名走 40ms 节拍、单字符无错峰)、确认时机,以及 reduced-motion 与空名的跳过路径。`apply.spec.ts` 端到端驱动跨屏暂存:创造模式草稿携带提示暂存,一次确认将其清除,重复确认让快照原样不动。`agent-preset-authoring` web e2e 在 golden 中保持空自定义分组(标题加创建入口)。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index 0763ffff02..55fe22e81b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -36,11 +36,13 @@ color: var(--dsw-alias-label-primary); } -/* Introduce cue: the icon eases in on an overshoot-free expo curve, then the - name's characters fade up on a stagger (delays set inline per character). - All chars occupy their width from the start, so nothing reflows mid-run. */ +/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration + matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands), + then the name's characters fade up on a stagger (delays set inline per + character). All chars occupy their width from the start, so nothing + reflows mid-run. */ .introIcon { - animation: seat-icon-in 0.6s cubic-bezier(0.16, 1, 0.3, 1) both; + animation: seat-icon-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both; } @keyframes seat-icon-in { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 84734dccfc..f7350076c2 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -36,13 +36,27 @@ export interface AgentPresetSeatInjected { introduced: () => void } -/* Introduce timeline: the icon eases in first; the name's characters start - fading up once the icon has mostly landed, one every stagger tick, each - taking the fade duration to settle. The cue clears after the last one. */ -const INTRO_TEXT_DELAY_MS = 300 -const INTRO_CHAR_STAGGER_MS = 60 +/* Introduce timeline: the icon eases in first (the CSS animation shares this + duration); the name's characters start fading up the moment it lands, each + taking the fade duration to settle. The cue clears after the last one. The + stagger is capped twice: per tick for short CJK names, and by one shared + reveal window so a long Latin name finishes in the same time as its CJK + counterpart instead of dragging the run out per character. */ +const INTRO_TEXT_DELAY_MS = 150 +const INTRO_CHAR_STAGGER_MS = 40 +const INTRO_TEXT_REVEAL_MS = 200 const INTRO_CHAR_FADE_MS = 400 +/** + * Per-character start offset for the introduce reveal. + * @param count - character count of the shown preset name. + * @returns milliseconds between successive character starts. + */ +function introStaggerMs(count: number): number { + if (count <= 1) return 0 + return Math.min(INTRO_CHAR_STAGGER_MS, INTRO_TEXT_REVEAL_MS / (count - 1)) +} + /** Full component props. */ export type AgentPresetSeatProps = PropsRuntime<'conversation.hero.agentPreset'> @@ -83,7 +97,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, const done = window.setTimeout(() => { setIntroducing(false) introduced() - }, INTRO_TEXT_DELAY_MS + characters.length * INTRO_CHAR_STAGGER_MS + INTRO_CHAR_FADE_MS) + }, INTRO_TEXT_DELAY_MS + (characters.length - 1) * introStaggerMs(characters.length) + INTRO_CHAR_FADE_MS) return () => { window.clearTimeout(done) } }, [state.introduce, ready, label, introduced]) @@ -93,14 +107,16 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, // One wrapper span: the chip is a flex row with a gap, so loose character // spans would each pick up the gap between them. + const characters = Array.from(label) + const stagger = introStaggerMs(characters.length) const shownLabel = introducing ? ( - {Array.from(label).map((character, index) => ( + {characters.map((character, index) => ( {character} diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 23e1944948..a886569037 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -496,6 +496,15 @@ describe('ui-agent-preset apply', () => { expect(section.startCreatorDraft).toBeDefined() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') expect(workspaces.starts).toHaveLength(1) + + // A cross-screen stage carries the introduce cue; the chip acknowledges + // it once, and a repeat acknowledgement leaves the snapshot untouched. + expect(seat.hooks.agentPresetSeat.getSnapshot().introduce).toBe(true) + seat.introduced() + const acknowledged = seat.hooks.agentPresetSeat.getSnapshot() + expect(acknowledged.introduce).toBe(false) + seat.introduced() + expect(seat.hooks.agentPresetSeat.getSnapshot()).toBe(acknowledged) conversation() }) diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx index b63b9ce63c..0c29175a60 100644 --- a/packages/client/ui-agent-preset/tests/components.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -277,6 +277,94 @@ describe('the new-session chip', () => { }) }) +describe('the chip introduce cue', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + /** Character spans carry inline animation delays; nothing else does. */ + function delayedChars(): HTMLElement[] { + return Array.from(screen.getByRole('button').querySelectorAll('[style]')) + } + + it('reveals a long Latin name inside the shared window, then acknowledges', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + vi.useFakeTimers() + const actions = renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: 'CreatorMode' }], + introduce: true, + }) + + // Eleven characters split the 200ms window into 20ms steps, where the + // fixed 40ms tick would have doubled the run for a Latin name. + const chars = delayedChars() + expect(chars.map(span => span.textContent).join('')).toBe('CreatorMode') + expect(chars[0]!.style.animationDelay).toBe('150ms') + expect(chars[1]!.style.animationDelay).toBe('170ms') + expect(chars[10]!.style.animationDelay).toBe('350ms') + + // 150 delay + 200 window + 400 fade: acknowledged only once the last + // character has settled, and the label is plain text again after. + act(() => { vi.advanceTimersByTime(749) }) + expect(actions.introduced).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(actions.introduced).toHaveBeenCalledTimes(1) + expect(delayedChars()).toHaveLength(0) + }) + + it('keeps the per-tick cap for a short CJK name', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + vi.useFakeTimers() + renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: '创造模式' }], + introduce: true, + }) + + // Four characters fit under the window, so the 40ms tick applies as-is. + const chars = delayedChars() + expect(chars).toHaveLength(4) + expect(chars[1]!.style.animationDelay).toBe('190ms') + expect(chars[3]!.style.animationDelay).toBe('270ms') + }) + + it('starts a one-character name with no stagger at all', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + vi.useFakeTimers() + const actions = renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: 'C' }], + introduce: true, + }) + + expect(delayedChars()[0]!.style.animationDelay).toBe('150ms') + act(() => { vi.advanceTimersByTime(550) }) + expect(actions.introduced).toHaveBeenCalledTimes(1) + }) + + it('skips the run under reduced motion and acknowledges at once', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) + const actions = renderSeat({ introduce: true }) + + expect(actions.introduced).toHaveBeenCalledTimes(1) + expect(delayedChars()).toHaveLength(0) + }) + + it('acknowledges an empty staged name without arming a run', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + const actions = renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: '' }], + introduce: true, + }) + + expect(actions.introduced).toHaveBeenCalledTimes(1) + expect(delayedChars()).toHaveLength(0) + }) +}) + describe('the session-header label', () => { it('names the preset the session runs, and never offers a switch', async () => { const { load } = renderLabel({ blank: false, agentPreset: 'standard' }) diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx index 05c2b28d67..93e7fdd1e5 100644 --- a/packages/client/ui-agent-preset/tests/section.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.spec.tsx @@ -253,6 +253,20 @@ describe('the preset list', () => { expect(actions.close).toHaveBeenCalledTimes(1) }) + it('keeps the empty custom group on screen: heading plus the creator entry', () => { + renderSection({ + rows: [ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式' }, + { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }, + ], + }) + + // No member yet, but the place where one's own preset will appear stays. + expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy() + expect(screen.getByRole('button', { name: en.creatorDraft })).toBeTruthy() + expect(screen.queryByText(`· ${en.userTrust}`)).toBeNull() + }) + it('hides the creator entry without the flow or the preset, disables it without a root', () => { renderSection() expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull() From 9e5135e338c89f7b3727a8031f6ee66ca46c9cd1 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 11 Aug 2026 00:00:07 +0800 Subject: [PATCH 31/77] =?UTF-8?q?fix(client):=20correct=20the=20Chinese=20?= =?UTF-8?q?hero=20slogan=20to=20=E6=8E=A2=E7=B4=A2=E6=9C=AA=E8=87=B3?= =?UTF-8?q?=E4=B9=8B=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped copy read 探索未知之境; the product slogan is 探索未至之境. English copy is untouched. --- packages/client/ui-conversation/src/client/locales.ts | 2 +- packages/client/ui-conversation/tests/skeleton.spec.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index b022219bc7..dcf04264e8 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -60,7 +60,7 @@ export const zh = { 'access.confirm.acknowledge': '我已了解风险,并愿意继续', 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', - 'hero.headline': '探索未知之境', + 'hero.headline': '探索未至之境', 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 7d7596a49e..ee05eb3a3e 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -356,7 +356,7 @@ describe('ConversationRoot resident composer', () => { const header = b.view.container.querySelector('header') expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') - expect(b.view.getByText('探索未知之境')).toBeTruthy() + expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the @@ -380,7 +380,7 @@ describe('ConversationRoot resident composer', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' })) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') - expect(b.view.queryByText('探索未知之境')).toBeNull() + expect(b.view.queryByText('探索未至之境')).toBeNull() }) it('settling phase: a session the list has no row for settles conservatively', () => { @@ -405,7 +405,7 @@ describe('ConversationRoot resident composer', () => { // blank the column for the history round-trip. const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('hero') - expect(b.view.getByText('探索未知之境')).toBeTruthy() + expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByRole('textbox')).toBeTruthy() }) @@ -423,7 +423,7 @@ describe('ConversationRoot resident composer', () => { expect(after.value).toBe('kept across flip') expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) - expect(b.view.queryByText('探索未知之境')).toBeNull() + expect(b.view.queryByText('探索未至之境')).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) From 442f0ef839dad5cf1b6d966ddb34a85ba8dcb0c2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:00:27 +0800 Subject: [PATCH 32/77] fix(mcp-client): bind strict sync to activation The supervisor selected strict startup registration with a shared isFirstSync flag. Because the MCP SDK may deliver tools/list_changed before connect() resolves, that notification could enter the sync queue first, consume the strict option inside its contained handler, and leave the actual activation sync non-fatal. Pass startup intent explicitly to connectGeneration(). Only the plugin activation attempt receives the failOnStartupError registration policy; notification-driven syncs and later reconnect generations always use contained runtime semantics. Queue arrival order can no longer redefine startup behavior. A regression test injects list_changed from inside connect(), keeps a foreign namespace squatter in place, and proves activation still rejects after the notification's contained sync. Focused package coverage remains 100%, and the bilingual reconnect note records the ownership rule. --- ...-08-06-mcp-client-auto-reconnect.i18n.yaml | 4 +-- .../2026-08-06-mcp-client-auto-reconnect.md | 4 +-- ...2026-08-06-mcp-client-auto-reconnect.zh.md | 4 +-- packages/mcp/mcp-client/src/connection.ts | 21 ++++++++------- packages/mcp/mcp-client/tests/apply.spec.ts | 26 +++++++++++++++++++ 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml index e0aac526cc..ffc985cc70 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.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-06-mcp-client-auto-reconnect.md -2026-08-06-mcp-client-auto-reconnect.md: e371fa13dc7bc5330f658f4d6e864969baf93cf0 -2026-08-06-mcp-client-auto-reconnect.zh.md: eb6e67dabd7c6115b9ecb507029ffa7d45338466 +2026-08-06-mcp-client-auto-reconnect.md: 75e17da716f817306bb30322678d26b0e18a5aff +2026-08-06-mcp-client-auto-reconnect.zh.md: 0ba06d17dca895be8449eb771d34dc096307d176 diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md index e371fa13dc..75e17da716 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md @@ -14,7 +14,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. **Trigger.** The supervisor arms `client.onclose` per generation. The SDK fires it when the stdio child exits, so a crash is observed without polling. `StreamableHTTPClientTransport` fires `onclose` only for deliberate closes — it owns its internal SSE-stream recovery and surfaces request failures per call — so HTTP servers are effectively outside supervisor restarts; the package README records that limitation. -**Generations without interleaving.** Each attempt builds a fresh transport and `Client` (the SDK binds a Protocol to one transport for life). One per-supervisor queue serializes every `syncTools` call — initial syncs and `list_changed` re-syncs across all generations — and an `isCurrent` fence makes stale generations inert, so no two syncs can interleave the dispose-previous/register-next swap (which would double-dispose one generation and leak another). The queue also closes a pre-existing race where two rapid `list_changed` notifications re-synced concurrently. Failure signals are idempotent per generation: a connect rejection racing its own transport close schedules exactly one retry. A failed attempt cannot enter backoff until both `Client.close()` settles and the transport reports `onclose`, which for stdio proves the child exited; a missing close signal stops reconnection after the SDK's bounded termination window instead of allowing two server processes to overlap. Disposal uses the same bounded close-signal barrier and reports an incomplete shutdown without ever restarting. +**Generations without interleaving.** Each attempt builds a fresh transport and `Client` (the SDK binds a Protocol to one transport for life). One per-supervisor queue serializes every `syncTools` call — initial syncs and `list_changed` re-syncs across all generations — and an `isCurrent` fence makes stale generations inert, so no two syncs can interleave the dispose-previous/register-next swap (which would double-dispose one generation and leak another). The queue also closes a pre-existing race where two rapid `list_changed` notifications re-synced concurrently. The activation attempt, rather than the first queue entrant, explicitly owns strict startup registration: an early `list_changed` notification uses contained re-sync semantics and cannot consume `failOnStartupError`. Failure signals are idempotent per generation: a connect rejection racing its own transport close schedules exactly one retry. A failed attempt cannot enter backoff until both `Client.close()` settles and the transport reports `onclose`, which for stdio proves the child exited; a missing close signal stops reconnection after the SDK's bounded termination window instead of allowing two server processes to overlap. Disposal uses the same bounded close-signal barrier and reports an incomplete shutdown without ever restarting. **Bounded backoff with an outage budget.** Delays double from `initialDelayMs` up to `maxDelayMs`. One outage shares `maxAttempts` consecutive failed attempts; exhaustion unregisters the server's tools, logs at error level, and stops until disposal or reload. A connection that survives past the stability window — `maxDelayMs`, derived rather than a fifth tunable, as the longest configured backoff spacing — resets the budget, so an occasionally-crashing server recovers indefinitely while a crash loop whose connects briefly succeed cannot launder its budget into a restart storm. @@ -38,7 +38,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. ## Testing -Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, failed initialization waits for the old generation's close signal and fails closed when that signal never arrives, disposal waits for the same signal with a bounded incomplete-shutdown path, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps the v1 behavior, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent. +Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, strict startup registration survives a pre-connect `list_changed` notification, failed initialization waits for the old generation's close signal and fails closed when that signal never arrives, disposal waits for the same signal with a bounded incomplete-shutdown path, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps the v1 behavior, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md index eb6e67dabd..0ba06d17dc 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md @@ -14,7 +14,7 @@ Status: implemented **触发条件。** 监督器在每一代上挂载 `client.onclose`。SDK 在 stdio 子进程退出时触发该回调,因此崩溃无需轮询即可感知。`StreamableHTTPClientTransport` 仅在主动关闭时触发 `onclose`——它内部拥有自己的 SSE(Server-Sent Events)流恢复机制,并将请求失败以逐调用方式暴露——因此 HTTP 服务器实际上不在监督器的重启范围内;包 README 记录了该限制。 -**代隔离,无交错。** 每次尝试构建一个全新的 transport 和 `Client`(SDK 将一个 Protocol 绑定到一个 transport 上终身使用)。每个监督器内部有一个队列将所有 `syncTools` 调用串行化——跨所有代的初始同步和 `list_changed` 再同步——`isCurrent` 栅栏使过时的代变为惰性,从而确保不会有两次同步交错执行 dispose 上一代/注册下一代的切换(否则会对同一代执行两次 dispose 并泄漏另一代)。该队列还消除了一个先前存在的竞态:两次快速的 `list_changed` 通知同时触发重新同步。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。失败尝试只有在 `Client.close()` 结算且 transport 报告 `onclose` 后才能进入退避;对 stdio 而言,`onclose` 证明子进程已退出;若关闭信号始终未到,则在 SDK 的有界终止窗口结束后停止重连,而不是允许两个服务器进程重叠运行。dispose 使用同一个有界关闭信号屏障;若关停未完成则予以报告,且绝不重启。 +**代隔离,无交错。** 每次尝试构建一个全新的 transport 和 `Client`(SDK 将一个 Protocol 绑定到一个 transport 上终身使用)。每个监督器内部有一个队列将所有 `syncTools` 调用串行化——跨所有代的初始同步和 `list_changed` 再同步——`isCurrent` 栅栏使过时的代变为惰性,从而确保不会有两次同步交错执行 dispose 上一代/注册下一代的切换(否则会对同一代执行两次 dispose 并泄漏另一代)。该队列还消除了一个先前存在的竞态:两次快速的 `list_changed` 通知同时触发重新同步。严格启动注册由激活尝试本身显式拥有,而非由首个入队者拥有;提前到达的 `list_changed` 采用故障隔离的再同步语义,不能消费 `failOnStartupError`。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。失败尝试只有在 `Client.close()` 结算且 transport 报告 `onclose` 后才能进入退避;对 stdio 而言,`onclose` 证明子进程已退出;若关闭信号始终未到,则在 SDK 的有界终止窗口结束后停止重连,而不是允许两个服务器进程重叠运行。dispose 使用同一个有界关闭信号屏障;若关停未完成则予以报告,且绝不重启。 **有界退避与故障预算。** 延迟从 `initialDelayMs` 起逐次翻倍,上限为 `maxDelayMs`。一次故障期间共享 `maxAttempts` 次连续失败尝试的预算;耗尽后注销该服务器的工具、以 error 级别记录日志并停止,直到 dispose 或重新加载。连接在存活超过稳定窗口——即 `maxDelayMs`,作为最长退避间隔从配置推导得出而非作为第五个独立调参项——之后重置预算;因此偶尔崩溃的服务器可无限恢复,而连接短暂成功后立即再次崩溃的循环无法将其预算洗白为重启风暴。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 +单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、严格启动注册在连接前收到 `list_changed` 通知后仍然生效、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 ## 后果 diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts index 2760324a20..3afd05b538 100644 --- a/packages/mcp/mcp-client/src/connection.ts +++ b/packages/mcp/mcp-client/src/connection.ts @@ -133,7 +133,6 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe const startupOpts: ToolBridgeOptions = config.failOnStartupError ? { ...opts, registrationFailure: 'throw' } : opts - let isFirstSync = true let disposed = false /** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */ @@ -160,9 +159,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe * generation and leak another). */ let syncChain: Promise = Promise.resolve() - function enqueueSync(generation: Client): Promise { - const syncOpts = isFirstSync ? startupOpts : opts - isFirstSync = false + function enqueueSync(generation: Client, syncOpts: ToolBridgeOptions = opts): Promise { const run = syncChain.then(async () => { if (!isCurrent(generation)) return disposers = await syncTools(generation, ctx, syncOpts, disposers) @@ -219,7 +216,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe ctx.logger.warn(`${label}: connection lost; reconnecting in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`) reconnectTimer = setTimeout(() => { reconnectTimer = undefined - settling = connectGeneration() + settling = connectGeneration(false) }, delayMs) // An armed reconnect timer must never hold the process open on its own. reconnectTimer.unref() @@ -228,10 +225,14 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe /** * One connection attempt: fresh transport + client (the MCP SDK binds a * Protocol to one transport for life), connect, then queue the initial tool - * sync. Every failure funnels through {@link generationDown}; success arms - * the onclose-driven disconnect path. Never rejects. + * sync. The startup flag belongs to the attempt rather than the shared sync + * queue, so an early notification cannot consume strict startup semantics. + * Every failure funnels through {@link generationDown}; success arms the + * onclose-driven disconnect path. Never rejects. + * + * @param startup - Whether this is the plugin's activation attempt. */ - async function connectGeneration(): Promise { + async function connectGeneration(startup: boolean): Promise { const generation = new Client( { name: 'dsh-mcp-client', version: '0.0.1' }, { capabilities: {} }, @@ -272,7 +273,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe generationDown(generation) return } - await enqueueSync(generation) + await enqueueSync(generation, startup ? startupOpts : opts) } catch (error) { if (firstAttemptError === undefined) firstAttemptError = error // Disposal clears current ownership before it closes the generation, so @@ -302,7 +303,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe } /** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */ - let settling = connectGeneration() + let settling = connectGeneration(true) // The ready promise settles when the first attempt finishes (regardless of // success). If the first attempt fails and reconnect is enabled, the diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index b6a9409c63..2d836d710e 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -288,6 +288,32 @@ describe('apply (plugin lifecycle)', () => { expect(mockClose).toHaveBeenCalled() }) + it('preserves strict startup registration when list_changed arrives before connect resolves', async () => { + ctx.tools.register({ + name: 'mcp__srv__remote', + description: 'Foreign squatter', + parameters: { type: 'object' }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, + execute: async () => 'foreign', + }) + mockConnect.mockImplementation(async () => { + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + await handler() + }) + + await expect(apply(ctx, { + ...stdioConfig, + failOnStartupError: true, + })).rejects.toThrow('initial connection or tool synchronization failed') + + expect(mockListTools).toHaveBeenCalledTimes(2) + expect(ctx.tools.get('mcp__srv__remote')?.description).toBe('Foreign squatter') + await ctx.fiber.dispose() + }) + it('re-syncs tools on ToolListChanged notification', async () => { await apply(ctx, stdioConfig) From bdd0e6a709378b6a6890fc3b60a6f8663496b26e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:01:31 +0800 Subject: [PATCH 33/77] test(mcp-client): pin give-up cleanup ordering The failure-cap path already appends tool disposal to syncChain, but the existing tests only covered give-up after settled discovery. They could not detect a future change that disposed the old set immediately and then allowed a blocked re-sync to publish a new leaked generation. Hold a list_changed fetch open, drive the reconnect budget to exhaustion, then release a different tool list. The test proves final cleanup runs after that in-flight swap and removes both the previous and late-published tool names while creating no attempt beyond the configured cap. --- .../mcp/mcp-client/tests/reconnect.spec.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/mcp/mcp-client/tests/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts index 6d85e753ea..8eb4bbf373 100644 --- a/packages/mcp/mcp-client/tests/reconnect.spec.ts +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -195,6 +195,32 @@ describe('reconnect supervisor', () => { expect(mockConnect).toHaveBeenCalledTimes(3) }) + it('gives up behind an in-flight re-sync and removes the generation it publishes', async () => { + const { errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 1 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + const gate: PromiseWithResolvers = Promise.withResolvers() + mockListTools.mockImplementation(() => gate.promise) + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + const resync = handler() + await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) }) + + mockConnect.mockRejectedValue(new Error('server gone')) + instances[0]!.onclose?.() + await vi.waitFor(() => { + expect(errors.some(line => line.includes('giving up after 1 consecutive failed reconnect attempts'))).toBe(true) + }) + + gate.resolve(listing('late')) + await resync + await vi.waitFor(() => { + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__late')).toBeUndefined() + }) + expect(mockConnect).toHaveBeenCalledTimes(2) + }) + it('does not start a replacement until a failed generation reports that it closed', async () => { mockConnect.mockRejectedValueOnce(new Error('initialize failed')) // Model the SDK's fire-and-forget close after initialize fails: the From 0e01036a2a50531ae4bee892232dcba89845835c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:02:13 +0800 Subject: [PATCH 34/77] test(mcp-client): preserve startup error cause Strict startup intentionally wraps connection and synchronization failures with the server-qualified activation diagnostic while retaining the original error in Error.cause. The prior assertion checked only the wrapper text, so the causal chain could regress unnoticed and erase the actionable transport failure. Assert the full wrapper message and object identity of the original connection error in cause. This keeps operator-facing context and the underlying SDK diagnostic independently stable without changing production behavior. --- packages/mcp/mcp-client/tests/apply.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 2d836d710e..d4f301be54 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -254,11 +254,15 @@ describe('apply (plugin lifecycle)', () => { }) it('rejects activation and still closes the client when startup failure is configured as fatal', async () => { - mockConnect.mockRejectedValue(new Error('connection refused')) + const cause = new Error('connection refused') + mockConnect.mockRejectedValue(cause) await expect(apply(ctx, { ...stdioConfig, failOnStartupError: true, - })).rejects.toThrow('initial connection or tool synchronization failed') + })).rejects.toMatchObject({ + message: 'mcp-client(srv): initial connection or tool synchronization failed', + cause, + }) expect(mockListTools).not.toHaveBeenCalled() expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() From e2556c51bf957bca4ddd0c8ffdd2c1e75fc9a9e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:05:06 +0800 Subject: [PATCH 35/77] fix(mcp-client): distinguish failure from loss The reconnect supervisor used connection lost for every transition into backoff, including an initial startup attempt that never established a connection and later retry attempts that also failed. That wording implied a previously healthy generation and obscured whether any tools had ever been registered. Capture whether the generation had reached the established state before scheduling recovery. Established disconnects retain connection lost/reconnecting; startup and retry failures now report connection failed/retrying. The reconnect-disabled diagnostic uses the same distinction while preserving its concrete manual-recovery guidance. Unit assertions cover established loss, initial failure, retry failure, and both reconnect-disabled branches. Focused package coverage remains 100%, and the bilingual Agent Note records the observable state vocabulary. --- .../2026-08-06-mcp-client-auto-reconnect.i18n.yaml | 4 ++-- .../feature/2026-08-06-mcp-client-auto-reconnect.md | 4 ++-- .../2026-08-06-mcp-client-auto-reconnect.zh.md | 4 ++-- packages/mcp/mcp-client/src/connection.ts | 12 +++++++----- packages/mcp/mcp-client/tests/reconnect.spec.ts | 6 +++++- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml index ffc985cc70..2f6713b4ab 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.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-06-mcp-client-auto-reconnect.md -2026-08-06-mcp-client-auto-reconnect.md: 75e17da716f817306bb30322678d26b0e18a5aff -2026-08-06-mcp-client-auto-reconnect.zh.md: 0ba06d17dca895be8449eb771d34dc096307d176 +2026-08-06-mcp-client-auto-reconnect.md: 99a8aec1abe3713822f8f17c17d8efaca5d61a4d +2026-08-06-mcp-client-auto-reconnect.zh.md: 8d4dc935e6edee9a05f556774e743e48234ca709 diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md index 75e17da716..99a8aec1ab 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md @@ -20,7 +20,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. **Config and resolution.** Both transports accept `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` with schemastery defaults (on, 500ms, 30s, 10). `resolveReconnectPolicy()` is the explicit resolve step: it re-judges every bound and cross-field constraint because programmatic construction may bypass Schemastery, and misconfiguration fails the plugin instance at load. -**Observable states.** Reconnecting logs at warn with attempt count and delay, recovery at info, final failure and disabled-loss at error. During an outage the last good generation stays registered and calls against it fail — deterministic public names mean a recovered unchanged tool list reproduces identical definitions, keeping the model-visible schema prefix stable instead of flapping. With `reconnect.enabled: false` a lost connection keeps the v1 manual-recovery behavior. +**Observable states.** An initial or retry-attempt failure says `connection failed`; an established generation ending says `connection lost`. Retrying logs at warn with attempt count and delay, recovery at info, final failure and disabled recovery at error. During an outage the last good generation stays registered and calls against it fail — deterministic public names mean a recovered unchanged tool list reproduces identical definitions, keeping the model-visible schema prefix stable instead of flapping. With `reconnect.enabled: false` a lost connection keeps the v1 manual-recovery behavior. **Disposal.** Dispose flips the fence, cancels any pending timer, closes the current client, then awaits the in-flight attempt and the sync queue before unregistering — quiescence, not just a request to stop. The reconnect timer is unref'd so a waiting backoff never holds a finishing process open. @@ -38,7 +38,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. ## Testing -Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, strict startup registration survives a pre-connect `list_changed` notification, failed initialization waits for the old generation's close signal and fails closed when that signal never arrives, disposal waits for the same signal with a bounded incomplete-shutdown path, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps the v1 behavior, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent. +Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, diagnostics distinguish initial or retry failure from established connection loss, strict startup registration survives a pre-connect `list_changed` notification, failed initialization waits for the old generation's close signal and fails closed when that signal never arrives, disposal waits for the same signal with a bounded incomplete-shutdown path, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps the v1 behavior, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md index 0ba06d17dc..8d4dc935e6 100644 --- a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md @@ -20,7 +20,7 @@ Status: implemented **配置与解析。** 两种传输均接受 `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` 配置,Schemastery 默认值为(启用、500ms、30s、10)。`resolveReconnectPolicy()` 是显式的解析步骤:它重新校验每个边界值和跨字段约束,因为程序化构造可能绕过 Schemastery,配置错误在加载时即令插件实例失败。 -**可观测状态。** 重连中以 warn 级别记录尝试次数和延迟,恢复以 info 级别记录,最终失败和禁用状态下的断连以 error 级别记录。故障期间,上一个正常代保持注册,对其工具的调用返回失败——确定性公开名称意味着恢复后未变化的工具列表会复现相同的定义,保持模型可见 schema 前缀稳定而非反复抖动。设置 `reconnect.enabled: false` 后,断连保持 v1 的手动恢复行为。 +**可观测状态。** 初始尝试或重试尝试失败时记录 `connection failed`,已建立的代结束时记录 `connection lost`;重试的 warn 日志包含尝试次数和延迟,恢复以 info 级别记录,最终失败和禁用重连时的断连以 error 级别记录。故障期间,上一个正常代保持注册,对其工具的调用返回失败——确定性公开名称意味着恢复后未变化的工具列表会复现相同的定义,保持模型可见 schema 前缀稳定而非反复抖动。设置 `reconnect.enabled: false` 后,断连保持 v1 的手动恢复行为。 **资源释放。** dispose 翻转栅栏、取消待执行的定时器、关闭当前 client,然后等待正在进行的尝试和同步队列完成后再注销工具——完全停稳,而非仅发出停止请求。重连定时器使用 unref,因此等待中的退避不会阻止进程正常退出。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、严格启动注册在连接前收到 `list_changed` 通知后仍然生效、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 +单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、诊断区分初始或重试尝试失败与已建立连接丢失、严格启动注册在连接前收到 `list_changed` 通知后仍然生效、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 ## 后果 diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts index 3afd05b538..f452e3a81d 100644 --- a/packages/mcp/mcp-client/src/connection.ts +++ b/packages/mcp/mcp-client/src/connection.ts @@ -190,11 +190,12 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe } function scheduleReconnect(): void { + const lostEstablishedConnection = connectedAt !== undefined if (!policy.enabled) { - const detail = connectedAt !== undefined - ? 'registered tools will fail until an HMR reload or Host restart' - : 'no tools were registered; reload the plugin or restart the Host to connect' - ctx.logger.error(`${label}: connection lost and reconnect is disabled — ${detail}`) + const message = lostEstablishedConnection + ? 'connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart' + : 'connection failed and reconnect is disabled — no tools were registered; reload the plugin or restart the Host to connect' + ctx.logger.error(`${label}: ${message}`) return } // A connection that stayed up past the stability window (= maxDelayMs, the @@ -213,7 +214,8 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe return } const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1)) - ctx.logger.warn(`${label}: connection lost; reconnecting in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`) + const action = lostEstablishedConnection ? 'connection lost; reconnecting' : 'connection failed; retrying' + ctx.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`) reconnectTimer = setTimeout(() => { reconnectTimer = undefined settling = connectGeneration(false) diff --git a/packages/mcp/mcp-client/tests/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts index 8eb4bbf373..bdbae26a7e 100644 --- a/packages/mcp/mcp-client/tests/reconnect.spec.ts +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -191,6 +191,7 @@ describe('reconnect supervisor', () => { // Initial connect + exactly maxAttempts reconnect attempts. expect(mockConnect).toHaveBeenCalledTimes(3) expect(warns.some(line => line.includes('connection attempt failed: Error: server gone'))).toBe(true) + expect(warns.some(line => line.includes('connection failed; retrying in 4ms (attempt 2/2)'))).toBe(true) await sleep(30) expect(mockConnect).toHaveBeenCalledTimes(3) }) @@ -222,6 +223,7 @@ describe('reconnect supervisor', () => { }) it('does not start a replacement until a failed generation reports that it closed', async () => { + const { warns } = captureLogs(ctx) mockConnect.mockRejectedValueOnce(new Error('initialize failed')) // Model the SDK's fire-and-forget close after initialize fails: the // harness's second close call returns, but the child has not exited yet. @@ -235,6 +237,7 @@ describe('reconnect supervisor', () => { instances[0]!.onclose?.() await applying await vi.waitFor(() => { expect(instances).toHaveLength(2) }) + expect(warns.some(line => line.includes('connection failed; retrying in 2ms (attempt 1/2)'))).toBe(true) }) it('stops reconnecting when a failed generation never reports that it closed', async () => { @@ -329,7 +332,7 @@ describe('reconnect supervisor', () => { expect(mockConnect).toHaveBeenCalledTimes(1) // Pre-reconnect contract: the generation stays registered until disposal. expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() - expect(errors.some(line => line.includes('reconnect is disabled'))).toBe(true) + expect(errors.some(line => line.includes('connection lost and reconnect is disabled'))).toBe(true) }) it('reconnect disabled after a failed initial connect reports no registered tools', async () => { const { errors } = captureLogs(ctx) @@ -337,6 +340,7 @@ describe('reconnect supervisor', () => { await apply(ctx, stdioConfig({ enabled: false })) await sleep(30) expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(errors.some(line => line.includes('connection failed and reconnect is disabled'))).toBe(true) expect(errors.some(line => line.includes('no tools were registered'))).toBe(true) }) From 580d85d2a8a4867a82c6ead01dc9e123f8bb943b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:28:00 +0800 Subject: [PATCH 36/77] fix(ci): await token stats before aria snapshot --- apps/web/tests/message-actions.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 7555be866b..4284d3d71a 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -128,6 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) await page.getByRole('button', { name: /^Select model, current/ }) .waitFor({ timeout: 10_000 }) + await page.getByText(/Cache hit \d+%/u).first().waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. await page.getByRole('button', { name: 'Copy' }).first().focus() From 6f9e62d24b5b8ab39ba359d3e4ad562d8ffeaee5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 11 Aug 2026 10:30:04 +0800 Subject: [PATCH 37/77] fix(examples): follow Cordis package rescope --- examples/acp-agent/image-text-route.cordis.snapshot.yml | 2 +- examples/acp-agent/image-text-route.cordis.yml | 2 +- examples/acp-agent/image.cordis.snapshot.yml | 2 +- examples/acp-agent/image.cordis.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/acp-agent/image-text-route.cordis.snapshot.yml b/examples/acp-agent/image-text-route.cordis.snapshot.yml index af7a8614ed..bd1c0e01ea 100644 --- a/examples/acp-agent/image-text-route.cordis.snapshot.yml +++ b/examples/acp-agent/image-text-route.cordis.snapshot.yml @@ -3,7 +3,7 @@ # text-only, so the strict read_image gate refuses and no image ever enters # the durable log. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/image-text-route.cordis.yml b/examples/acp-agent/image-text-route.cordis.yml index 96ef510ab3..bbb5b9b4c0 100644 --- a/examples/acp-agent/image-text-route.cordis.yml +++ b/examples/acp-agent/image-text-route.cordis.yml @@ -5,7 +5,7 @@ # authored fixture and the pinned header class are flash), because a config # patch replaces the whole app config. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/image.cordis.snapshot.yml b/examples/acp-agent/image.cordis.snapshot.yml index e4f8aecdb0..f056f775d3 100644 --- a/examples/acp-agent/image.cordis.snapshot.yml +++ b/examples/acp-agent/image.cordis.snapshot.yml @@ -5,7 +5,7 @@ # flash, so the strict read_image gate accepts the route and the tool result # carries the durable image block; the live DeepSeek route cannot record this. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/image.cordis.yml b/examples/acp-agent/image.cordis.yml index d306afe735..a12cef1e85 100644 --- a/examples/acp-agent/image.cordis.yml +++ b/examples/acp-agent/image.cordis.yml @@ -5,7 +5,7 @@ # authored fixture and the pinned header class are flash), because a config # patch replaces the whole app config. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: From fd5365e374115b3aa0ec51e6f14414febe9bf607 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 10:44:09 +0800 Subject: [PATCH 38/77] chore(packages): remove empty experimental group --- ...07-28-experimental-plugin-package-group.md | 33 ----------------- ...28-experimental-plugin-package-group.zh.md | 33 ----------------- .../2026-07-29-package-regrouping.i18n.yaml | 4 +- .../2026-07-29-package-regrouping.md | 2 +- .../2026-07-29-package-regrouping.zh.md | 2 +- ...mpty-experimental-package-group.i18n.yaml} | 6 +-- ...remove-empty-experimental-package-group.md | 37 +++++++++++++++++++ ...ove-empty-experimental-package-group.zh.md | 37 +++++++++++++++++++ packages/README.i18n.yaml | 4 +- packages/README.md | 1 - packages/README.zh.md | 1 - packages/experimental/AGENTS.md | 11 ------ packages/experimental/README.i18n.yaml | 6 --- packages/experimental/README.md | 7 ---- packages/experimental/README.zh.md | 7 ---- 15 files changed, 83 insertions(+), 108 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md delete mode 100644 .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md rename .agents/notes/implemented/{architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml => simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml} (52%) create mode 100644 .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md create mode 100644 .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md delete mode 100644 packages/experimental/AGENTS.md delete mode 100644 packages/experimental/README.i18n.yaml delete mode 100644 packages/experimental/README.md delete mode 100644 packages/experimental/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md deleted file mode 100644 index 1ebae5dbb1..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: Experimental and internal package group - -Status: implemented - -English | [中文](2026-07-28-experimental-plugin-package-group.zh.md) - -## Problem - -The [package hierarchy](../../../../packages/README.md) groups plugins by product role, but it cannot distinguish release packages from prototypes or internal-only packages. The team needs an obvious shared place for useful work that is not part of the official release. - -## Decision - -The subtree rules in [`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) make `packages/experimental//` the required home for Cordis plugin packages whose whole public contract is experimental or internal-only. Package names remain `@deepseek-ai/dsh-`. - -The group is the team's in-repository place to share engineering and product-manager prototypes: members can discover, run, review, and extend one another's work against the real plugin graph without implying product support. - -Official releases exclude this directory. A package enters a release only after moving to its product-role group; release packages cannot take runtime dependencies on packages here. Examples may use them, while any other runtime dependent also belongs here. Tests may use them as development dependencies. - -Experimental packages carry no stability, compatibility, migration, or support promise: they may change APIs, configuration, or data, or disappear without deprecation or migration. Internal-only packages may define narrower internal contracts but make no public release promise. Neither status relaxes engineering, security, documentation, lifecycle, testing, or snapshot requirements. - -The pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and `/btw` plugin are examples governed by this rule. Promotion into an official release requires explicit review of the public contract, limitations, test evidence, and a named owner accepting stable-package obligations. - -## Alternatives considered - -**Keep experimental and internal-only packages in product-role groups with README labels.** Labels are easy to miss and cannot enforce dependency boundaries. - -**Treat every package as experimental until the first tagged release.** This provides no durable incubation boundary. - -**Develop prototypes and internal packages elsewhere.** This loses the real plugin graph, examples, snapshots, and lifecycle checks needed to evaluate them. - -## Consequences - -The path makes release exclusion and dependency blast radius visible while retaining the real plugin graph for team sharing. It gives up product-role colocation and creates path churn on promotion, while the npm name remains stable. The subtree rules, repository [current-owner/current-need rule](../../../../packages/AGENTS.md), and unchanged engineering gates limit junk-drawer growth. Because official release tooling does not yet exist, contributor policy enforces the exclusion; when such tooling is added, the directory is its required exclusion boundary. diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md deleted file mode 100644 index 2d09451c50..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: 实验性与内部专用包分组 - -Status: implemented - -[English](2026-07-28-experimental-plugin-package-group.md) | 中文 - -## 问题 - -[包层级结构](../../../../packages/README.md)按产品角色对插件分组,但无法区分发布包、原型和内部专用包。团队需要一个明确的共享位置,存放不属于官方发布版本的有价值成果。 - -## 决策 - -[`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) 中的子树规则要求所有公开约定整体处于实验状态或仅限内部使用的 Cordis 插件包位于 `packages/experimental//`。包名仍为 `@deepseek-ai/dsh-`。 - -该分组供团队在仓库内共享工程人员和产品经理制作的原型:成员可以基于真实插件图发现、运行、评审并扩展彼此的原型,但这不代表产品会提供支持。 - -官方发布版本不包含此目录。包只有移入对应的产品角色分组后才会纳入发布版本;发布包不得在运行时依赖此处的包。示例可以使用这些包;其他任何运行时依赖方也必须位于此处。测试可以将它们用作开发依赖。 - -实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置或数据可以变更,包也可以移除,均不提供弃用期或迁移路径。内部专用包可以定义范围更窄的内部约定,但不作公开发布承诺。无论哪种状态,都不降低仓库对工程、安全、文档、生命周期、测试或快照的要求。 - -尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件都受这项规则约束。将包提升为稳定包并纳入官方发布版本,需要明确评审其公开约定、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。 - -## 考虑过的替代方案 - -**将实验性和内部专用包留在产品角色分组中,并用 README 标注。** 标注容易被忽略,也无法强制执行依赖边界。 - -**首个带标签的版本发布前,将所有包都视为实验性。** 这无法提供持久的孵化边界。 - -**在其他位置开发原型和内部专用包。** 这会失去评估它们所需的真实插件图、示例、快照和生命周期检查。 - -## 后果 - -该路径明确标示不纳入发布版本的包及其依赖影响范围,同时保留供团队共享成果的真实插件图。代价是这些包无法与同产品角色的包共置,提升并纳入发布版本时还会产生路径变动,但 npm 包名保持稳定。子树规则、仓库已有的[「必须有当前负责人和实际需求」规则](../../../../packages/AGENTS.md)以及保持不变的工程门禁,可限制该分组无序膨胀。由于官方发布工具尚不存在,目前由贡献者政策执行这项排除规则;添加发布工具后,必须以该目录为排除边界。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml index c50bf478ad..f5c1e4cad3 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.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-29-package-regrouping.md -2026-07-29-package-regrouping.md: 3c37bce05bacd6af800a76ac93fb691b896a6772 -2026-07-29-package-regrouping.zh.md: 68903ff1fad6a975c4445fe8971c8fe0dd40117f +2026-07-29-package-regrouping.md: 30fc45a122263350b4a2ad1998850f631c20f9b8 +2026-07-29-package-regrouping.zh.md: a3a9a11ec71b7f894dcea7c733eb39a80b71ac50 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md index 3c37bce05b..30fc45a122 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md @@ -58,7 +58,7 @@ The moves landed as pure `git mv` moves, so rename detection carries the history A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot fixtures, the `pnpm-workspace.yaml`/`tsdown` globs (both `packages/*/*`), or the Python runtime manifest — all reference packages by npm name. -`client/` and `host/` were out of scope and are unchanged. The `experimental/` group proposal (PR #844) is orthogonal — a release-boundary container, not a clustering decision. +`client/` and `host/` were out of scope and are unchanged. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md index 68903ff1fa..a3a9a11ec7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md @@ -58,7 +58,7 @@ Status: implemented 组移动未触及:npm 包名、import、`cordis.yml` 配置、快照 fixture(测试前置数据)、`pnpm-workspace.yaml` 与 `tsdown` 的 glob(都是 `packages/*/*`),以及 Python 运行时 manifest(元数据清单)——它们全部按 npm 包名引用包。 -`client/` 与 `host/` 不在本次范围内,保持不变。`experimental/` 组提案(PR #844)与本案正交:它是发布边界容器,不是聚类决策。 +`client/` 与 `host/` 不在本次范围内,保持不变。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml similarity index 52% rename from .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml index 43e4b3d605..945a68054c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml @@ -1,6 +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-07-28-experimental-plugin-package-group.md -2026-07-28-experimental-plugin-package-group.md: 1ebae5dbb16d4c966f94ffde69fb0cb9bc163d80 -2026-07-28-experimental-plugin-package-group.zh.md: 2d09451c5069a775906e5bc8748c334c29008164 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md +2026-08-11-remove-empty-experimental-package-group.md: e5e81e3e3763f216921b3f3b74709b64be3dee37 +2026-08-11-remove-empty-experimental-package-group.zh.md: d44d0daaf346a5fea317f8c8c6a23f26eaa3cec0 diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md new file mode 100644 index 0000000000..e5e81e3e37 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md @@ -0,0 +1,37 @@ +# Agent Note: Remove the empty experimental package group + +Status: implemented + +English | [中文](2026-08-11-remove-empty-experimental-package-group.zh.md) + +## Problem + +The package hierarchy reserves `packages/experimental/` for prototypes and internal-only plugins, but no package has used the group. The empty group adds placement, dependency, promotion, and release rules without a current package or release mechanism that needs them. + +The original group aimed to let the team share prototypes against the real plugin graph without implying product support. That need remains possible, but it does not justify a permanent repository category before a concrete package exists. + +## Decision + +The package hierarchy has no reserved experimental or internal-only group. Packages continue to live in groups selected for their current product role. + +A concrete package that needs different release, stability, or dependency treatment requires a decision based on its actual consumers and release mechanism. That decision may reintroduce a dedicated group when it can also define and enforce the exclusion rules. + +This note consolidates and supersedes the experimental-package-group decision, whose active triplet is removed with the empty directory. + +## Alternatives considered + +**Keep the empty group.** It provides an obvious future incubation location, but it also keeps repository rules with no current owner, package, or enforcement mechanism. + +**Move the experimental rules into the general package instructions.** This preserves the policy without an empty directory, but makes every package change carry rules for a hypothetical package class. + +**Put concrete experimental packages in product-role groups with README labels.** This preserves product-role colocation, but labels alone cannot enforce release and runtime-dependency rules. A future package can evaluate this option against its actual release mechanism. + +**Treat every package as experimental until the first tagged release.** This applies a broad temporary status without providing durable treatment for packages that remain experimental after releases begin. + +**Require prototypes to stay outside the repository.** This would lose access to the real plugin graph, examples, snapshots, and lifecycle checks. Removing the reserved group does not impose that restriction; a concrete prototype can establish the placement it needs. + +## Consequences + +The hierarchy loses an unused group and its special release and dependency policy. It also gives up a predeclared location for team discovery and a ready-made promotion path. + +The first package that needs experimental or internal-only treatment must define where it lives, how releases exclude it, which runtime dependencies are allowed, and what condition promotes or removes it. A dedicated group can return when those rules have a current consumer and enforceable mechanism. diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md new file mode 100644 index 0000000000..d44d0daaf3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 移除空的实验性包分组 + +Status: implemented + +[English](2026-08-11-remove-empty-experimental-package-group.md) | 中文 + +## 问题 + +包层级结构预留 `packages/experimental/` 用于原型和内部专用插件,但从未有包使用该分组。这个空分组添加了放置、依赖、提升和发布规则,却没有需要这些规则的现有包或发布机制。 + +原分组旨在让团队基于真实插件图共享原型,同时不暗示产品会提供支持。这项需求将来可能出现,但在具体包出现前,不足以支持一个永久的仓库类别。 + +## 决策 + +包层级结构不再预留实验性或内部专用分组。包继续按照当前产品职责放入对应分组。 + +如果具体包需要不同的发布、稳定性或依赖处理,必须根据其实际消费方和发布机制做出决策。只要该决策同时定义并强制执行排除规则,就可以重新引入专用分组。 + +本 Agent Note 整合并取代实验性包分组决策;该旧决策的活跃三文件组随空目录一并移除。 + +## 考虑过的替代方案 + +**保留空分组。** 它为未来孵化工作提供明确位置,但也会保留没有当前负责人、包或强制执行机制的仓库规则。 + +**将实验性规则移入通用包指令。** 这可以在不保留空目录的情况下延续政策,但会让每次包变更都携带针对假设包类别的规则。 + +**将具体实验性包放入产品职责分组,并用 README 标注。** 这会保持产品职责共置,但仅靠标注无法强制执行发布和运行时依赖规则。未来的包可以根据实际发布机制评估此选项。 + +**在首个带标签的版本发布前,将每个包都视为实验性。** 这会施加宽泛的临时状态,却无法为发布开始后仍处于实验状态的包提供持久处理方式。 + +**要求原型留在仓库外。** 这会失去真实插件图、示例、快照和生命周期检查。移除预留分组并不施加这项限制;具体原型可以建立自身所需的放置规则。 + +## 后果 + +包层级结构移除了未使用的分组及其特殊发布和依赖政策,同时也放弃了预先声明的团队发现位置和现成的提升路径。 + +第一个需要实验性或内部专用处理的包必须定义其存放位置、发布版本如何排除它、允许哪些运行时依赖,以及包在何种条件下获得提升或被移除。当这些规则具有当前消费方和可强制执行的机制时,可以恢复专用分组。 diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index b4c191a1d0..5f2d8b0585 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: 19d6e5ba7b554f59bd66e213f8a53389761fc735 -README.zh.md: 17f58a2922e9019af054b0dccb6c4d9199fd1a9d +README.md: eb7df95bde10dafd7afcb168d30c9dda90296687 +README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a diff --git a/packages/README.md b/packages/README.md index 19d6e5ba7b..eb7df95bde 100644 --- a/packages/README.md +++ b/packages/README.md @@ -52,7 +52,6 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface | | [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface | | [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface | -| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | diff --git a/packages/README.zh.md b/packages/README.zh.md index 17f58a2922..03cd025102 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -52,7 +52,6 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 | | [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定接口 | | [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 | -| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 | | [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 | | [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | | [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | diff --git a/packages/experimental/AGENTS.md b/packages/experimental/AGENTS.md deleted file mode 100644 index ee6bf61598..0000000000 --- a/packages/experimental/AGENTS.md +++ /dev/null @@ -1,11 +0,0 @@ -# AGENTS.md — Experimental and internal packages - -These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale. - -- All Cordis plugin packages whose full public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group. -- Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph. -- Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles. -- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define contracts for a limited set of internal callers and callees but make no public release promise. -- Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements. -- Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies. -- Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations. diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml deleted file mode 100644 index 48cfed37ee..0000000000 --- a/packages/experimental/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/experimental/README.md -README.md: db39af8bb1b1bcfd257e16e4ad1dd112f604ffb1 -README.zh.md: fc5942190a354668164b41b99e83b52b14d88f18 diff --git a/packages/experimental/README.md b/packages/experimental/README.md deleted file mode 100644 index db39af8bb1..0000000000 --- a/packages/experimental/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# experimental/ — experimental and internal packages - -English | [中文](README.zh.md) - -This group hosts team-shared engineering and product-manager prototypes plus internal-only Cordis plugins. It is excluded from official releases; packages move to their product-role group before release. - -No packages live here yet. The [subtree rules](AGENTS.md) define the no-warranty, dependency, and promotion boundaries. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md deleted file mode 100644 index fc5942190a..0000000000 --- a/packages/experimental/README.zh.md +++ /dev/null @@ -1,7 +0,0 @@ -# experimental/:实验性与内部专用包 - -[English](README.md) | 中文 - -该分组容纳工程人员与产品经理在团队内共享的原型,以及内部专用 Cordis 插件。该分组不纳入官方发布版本;包在发布前移入对应的产品角色分组。 - -该分组尚未包含任何包。[子树规则](AGENTS.md)界定不作保证、依赖关系和提升机制的边界。 From 94abd8631ae83d2ff1e65a422e8a59abfb7d369c Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 7 Aug 2026 16:01:41 +0800 Subject: [PATCH 39/77] fix(feedback): include session id in acknowledgement --- .../feedback/command-feedback/src/index.ts | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 37205b76e2..8922df008e 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -8,6 +8,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' +import type { Telemetry, TelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry' import type { Session } from '@deepseek-ai/dsh-session' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' @@ -16,6 +17,42 @@ export const inject = ['commands'] const USAGE = 'Usage: /feedback ' +/** Fail closed when a future sharing status reaches the sentence switch. */ +/* v8 ignore next 3 -- only the ignored default arm calls this; the closed union cannot reach it via the public API. */ +function assertNever(value: never): never { + throw new Error(`command-feedback: unsupported sharing status ${JSON.stringify(value)}`) +} + +/** The acknowledgement's sharing sentence for a disclosed policy. */ +function sharingSentence(sharing: TelemetrySharingStatus): string { + switch (sharing) { + case 'full': + return 'Session sharing is enabled.' + case 'feedback-only': + return 'Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.' + case 'disabled': + return 'Session sharing is disabled.' + /* v8 ignore next 2 -- the seam's closed union cannot reach the default; a future status must be given a sentence here. */ + default: + return assertNever(sharing) + } +} + +/** + * The sharing disclosure appended to the acknowledgement: the mounted + * backend's disclosed policy, or a "not configured" notice when no backend + * is mounted. Read through the plugin context so the command still works + * when the telemetry service is absent. + * @param telemetry - the mounted telemetry service, or undefined. + * @returns one sentence describing this session's sharing policy. + */ +function sharingDisclosure(telemetry: Telemetry | undefined): string { + if (telemetry === undefined) { + return 'Session sharing is not configured.' + } + return sharingSentence(telemetry.sharing) +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** @@ -42,17 +79,20 @@ export function recordFeedback(session: Session, text: string): void { * Validate, record, and acknowledge one feedback entry. Returning an error * leaves no `feedback/record` event. * @param invocation - receiving agent, raw command input, and UI cancellation. + * @param ctx - plugin context used to read the optional telemetry service. * @returns an acknowledgement containing the receiving session and anonymous - * user ids, or a usage error when no feedback text was supplied. + * user ids plus the session-sharing disclosure, or a usage error when no + * feedback text was supplied. */ -function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { +function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): CommandResult { if (invocation.rawInput.trim().length === 0) { return { kind: 'error', text: `Feedback text is required. ${USAGE}` } } recordFeedback(invocation.agent.session, invocation.rawInput) + const telemetry = ctx.get('telemetry') return { kind: 'success', - text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}`, + text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}. ${sharingDisclosure(telemetry)}`, } } @@ -63,6 +103,6 @@ export function apply(ctx: Context): void { description: 'record feedback about this session', input: { hint: '' }, recordInput: false, - handler: executeFeedbackCommand, + handler: invocation => executeFeedbackCommand(invocation, ctx), }) } From 3f9d0436eb4ec1b070ae49a651091044a80ad9d6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 8 Aug 2026 02:27:37 +0800 Subject: [PATCH 40/77] feat(feedback): disclose session sharing in the /feedback acknowledgement The /feedback acknowledgement now echoes the receiving session id and reports the mounted telemetry backend's sharing policy: the telemetry seam exposes a backend-independent TelemetrySharingStatus through a required abstract sharing member on the Telemetry service, the OTel backend maps its mode onto it, and the command appends one policy-only sharing sentence (full / feedback-only / disabled / not configured) to the acknowledgement. The web client renders the text through the existing command row without a client change; a new assembled-browser e2e mounts the shipped telemetry row in FULL mode against a local dead endpoint and pins the shipped default sentence as a keyless golden. --- .../2026-07-28-feedback-command.i18n.yaml | 4 +- .../feature/2026-07-28-feedback-command.md | 2 +- .../feature/2026-07-28-feedback-command.zh.md | 2 +- ...knowledgement-sharing-disclosure.i18n.yaml | 6 ++ ...back-acknowledgement-sharing-disclosure.md | 27 ++++++ ...k-acknowledgement-sharing-disclosure.zh.md | 27 ++++++ apps/web/tests/feedback-command.e2e.ts | 89 +++++++++++++++++++ apps/web/tests/scaffold.ts | 15 +++- .../feedback-command/ack.expected.md | 35 ++++++++ .../snapshots/feedback-command/session.jsonl | 17 ++++ apps/web/tsconfig.json | 1 + packages/feedback/command-feedback/README.md | 16 +++- .../feedback/command-feedback/README.zh.md | 16 +++- .../feedback/command-feedback/package.json | 2 + .../feedback/command-feedback/src/index.ts | 5 ++ .../tests/command-feedback.spec.ts | 57 ++++++++++-- .../tests/loader-composition.spec.ts | 2 +- .../session-telemetry-otel/README.i18n.yaml | 4 +- .../session/session-telemetry-otel/README.md | 2 + .../session-telemetry-otel/README.zh.md | 2 + .../session-telemetry-otel/src/index.ts | 14 +++ .../session-telemetry-otel/tests/otel.spec.ts | 25 ++++++ .../session-telemetry/README.i18n.yaml | 4 +- packages/session/session-telemetry/README.md | 6 ++ .../session/session-telemetry/README.zh.md | 8 ++ .../session/session-telemetry/src/index.ts | 18 ++++ scripts/type-equiv.manifest.json | 5 ++ tsconfig.host.json | 1 + 28 files changed, 394 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md create mode 100644 .agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md create mode 100644 apps/web/tests/feedback-command.e2e.ts create mode 100644 apps/web/tests/snapshots/feedback-command/ack.expected.md create mode 100644 apps/web/tests/snapshots/feedback-command/session.jsonl diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml index 809e37044f..e0ba016659 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.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-28-feedback-command.md -2026-07-28-feedback-command.md: 3edb29283c289d6d006891a4c19087b01fa8166f -2026-07-28-feedback-command.zh.md: c2513d2570474cbbaf8d94f87603d8ce10d40c14 +2026-07-28-feedback-command.md: d3b2774e41a82f6edb4303280f813ddbed75ebd1 +2026-07-28-feedback-command.zh.md: 3eeef92f2ed39c9546f013f217dd7f851d30c78c diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md index 3edb29283c..d3b2774e41 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -18,7 +18,7 @@ The package declares the log-only `feedback/record { text }` session event and e `dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends enter persistence's ordinary bounded write path; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk. -Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md). +Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md) and the [acknowledgement sharing disclosure](2026-08-07-feedback-acknowledgement-sharing-disclosure.md). ### Why feedback owns an event diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md index c2513d2570..3eeef92f2e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -18,7 +18,7 @@ Status: implemented `dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会进入持久化的常规有界写入路径;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 -采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。 +采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)与[确认文本中的共享披露](2026-08-07-feedback-acknowledgement-sharing-disclosure.md)。 ### 为何反馈拥有自己的事件 diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml new file mode 100644 index 0000000000..b5c7f142f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.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-07-feedback-acknowledgement-sharing-disclosure.md +2026-08-07-feedback-acknowledgement-sharing-disclosure.md: 1e9cd0fb95d78aff9f6434e0583154e2c3f847da +2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md: ac26b18ad523feeabc297b212210dd73eff93a0a diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md new file mode 100644 index 0000000000..1e9cd0fb95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md @@ -0,0 +1,27 @@ +# Agent Note: Feedback acknowledgement sharing disclosure + +Status: implemented + +English | [中文](2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md) + +## Problem + +The `/feedback` command records a log-only `feedback/record` event and acknowledges the user, but the acknowledgement carried no durable context about what happened to the session: deployments that mount session telemetry (`FULL`, `FEEDBACK_ONLY`, or `DISABLED`) had no way to tell the user whether their feedback and session left the process, and the receiving session id was not echoed. The command plugin could not read the sharing policy because the telemetry seam exposed capture only, and the OTel mode enum lived in the optional backend package. + +## Decision + +The telemetry seam (`@deepseek-ai/dsh-session-telemetry`) now owns a backend-independent sharing vocabulary: `TelemetrySharingStatus` (`full` | `feedback-only` | `disabled`) plus a required abstract `sharing` member on the `Telemetry` service class — every backend must disclose its policy, so a consumer renders "not configured" only when no telemetry service is mounted. `@deepseek-ai/dsh-session-telemetry-otel` maps its serialized `TelemetryMode` (the [feedback-gated delivery decision](2026-08-05-feedback-gated-session-telemetry.md) owns the mode semantics) onto that status in the constructor and discloses it, including in `DISABLED`. The `/feedback` handler reads the mounted service through the plugin context (`ctx.get('telemetry')`, never a declared injection, so the command loads and runs without telemetry) and appends one sharing sentence to the acknowledgement: `Feedback recorded for session {id}. `. No service → `Session sharing is not configured.`; `disabled` → `Session sharing is disabled.`; `feedback-only` → `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`; `full` → `Session sharing is enabled.` + +The disclosure states the current sharing policy only; it never promises delivery or retention. Handoff is the backend's non-blocking enqueue and batching, retry, and loss policy stay the backend SDK's, and a later reconfiguration can change what was shared, so the sentences claim nothing about what reached a collector or about future retention. The disclosure adds no session event and never reaches the model surface; the web client renders it through the existing command row (`CommandNode` outcome text) with no client change. + +## Alternatives considered + +**A client-side status RPC and badge.** Rejected because the acknowledgement is host-produced and the web client already renders the command result text verbatim in the command row; a separate RPC would duplicate the status in a second surface and add a wire contract for a sentence. + +**Declared `telemetry` injection in `command-feedback`.** Rejected because telemetry is optional: a declared injection fails plugin load when the service is absent, while the command must work without it. The plugin reads the service with `ctx.get('telemetry')` at handler time instead. + +**OTel package owns the vocabulary.** Rejected because `command-feedback` must not depend on the optional OTel backend package. The seam owns `TelemetrySharingStatus` so any backend can disclose a policy. + +## Consequences + +The acknowledgement is user-visible: it names the receiving session and reports the current sharing policy, honest about the fire-and-forget handoff. Package tests pin the sentence for each status and for the absent-service case; the assembled-browser e2e mounts the shipped telemetry row in FULL mode against a local dead endpoint and pins the shipped default sentence (`Session sharing is enabled.`) as a golden. The seam member is required, so a mounted backend always discloses a policy and the "not configured" sentence truthfully means no telemetry service; the `/feedback` command keeps working with no telemetry mounted. A still-blank web session renders no command row, so feedback recorded before the first message gets no visible acknowledgement (documented under the package README's limitations). diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md new file mode 100644 index 0000000000..ac26b18ad5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 反馈确认中的会话共享披露 + +Status: implemented + +[English](2026-08-07-feedback-acknowledgement-sharing-disclosure.md) | 中文 + +## 问题 + +`/feedback` 命令会记录一个仅写入日志的 `feedback/record` 事件并确认用户,但确认文本没有携带关于会话去向的持久信息:挂载了会话遥测(`FULL`、`FEEDBACK_ONLY` 或 `DISABLED`)的部署无法告知用户其反馈和会话是否离开了进程,确认文本也没有回显接收会话的 id。命令插件无法读取共享策略,因为遥测 seam 只暴露采集能力,而 OTel 模式枚举位于可选的后端包中。 + +## 决策 + +遥测 seam(`@deepseek-ai/dsh-session-telemetry`)现在拥有与后端无关的共享词汇:`TelemetrySharingStatus`(`full` | `feedback-only` | `disabled`),并在 `Telemetry` 服务类上增加一个必需的抽象 `sharing` 成员——每个后端都必须披露其策略,因此消费方只有在未挂载任何遥测服务时才渲染「未配置」。`@deepseek-ai/dsh-session-telemetry-otel` 在构造函数中把序列化的 `TelemetryMode`(模式语义由[反馈门控投递决策](2026-08-05-feedback-gated-session-telemetry.md)负责)映射到该状态并披露,包括 `DISABLED` 模式。`/feedback` 处理器通过插件上下文读取已挂载的服务(`ctx.get('telemetry')`,绝不是声明的注入,因此命令在无遥测时也能加载和运行),并在确认文本后追加一句共享披露:`Feedback recorded for session {id}. <句子>`。无服务 → `Session sharing is not configured.`;`disabled` → `Session sharing is disabled.`;`feedback-only` → `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`;`full` → `Session sharing is enabled.` + +披露只陈述当前的共享策略,绝不承诺投递或留存:交接是后端的非阻塞入队,批处理、重试与丢失策略仍归后端 SDK,且后续重新配置可能改变已共享的内容,因此句子不声称任何内容已到达采集端,也不声称未来的留存。披露不新增任何会话事件,也绝不会进入模型 surface;Web 客户端通过现有的命令行(`CommandNode` 的结果文本)原样渲染,无需客户端改动。 + +## 备选方案 + +**客户端新增状态 RPC 与徽标。** 拒绝,因为确认文本由宿主生成,Web 客户端已经在命令行中原样渲染命令结果文本;单独的 RPC 会在第二个 surface 重复该状态,并为一句文案新增线上契约。 + +**在 `command-feedback` 中声明 `telemetry` 注入。** 拒绝,因为遥测是可选的:服务缺失时声明注入会导致插件加载失败,而命令必须在无遥测时可用。插件改为在处理器执行时用 `ctx.get('telemetry')` 读取服务。 + +**由 OTel 包拥有词汇。** 拒绝,因为 `command-feedback` 不能依赖可选的 OTel 后端包。seam 拥有 `TelemetrySharingStatus`,任何后端都能披露策略。 + +## 后果 + +确认文本对用户可见:它点名接收会话并报告当前的共享策略,如实说明 fire-and-forget 交接。包级测试为每种状态以及无服务场景固定句子;组装浏览器 e2e 以 FULL 模式挂载随附的遥测行(指向本地 dead 端点),并以 golden 固定随附默认句子(`Session sharing is enabled.`)。seam 成员是必需的,因此已挂载的后端总会披露策略,「未配置」句子如实地表示没有遥测服务;`/feedback` 命令在未挂载遥测时仍能正常工作。仍为空白的新 Web 会话不渲染命令行,因此首条消息之前记录的反馈没有可见确认(已在包 README 的限制中记录)。 diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts new file mode 100644 index 0000000000..6dd8ac19a0 --- /dev/null +++ b/apps/web/tests/feedback-command.e2e.ts @@ -0,0 +1,89 @@ +// Keyless assembled-browser coverage for the /feedback command over the +// shipped Web bundles and the real host wire. The command plane settles +// without a model turn: the host appends the log-only command/run + +// feedback/record + command/done lifecycle, and the transcript renders the +// acknowledgement — the recorded session id plus the session-sharing +// disclosure — as a persistent command row. The scaffold mounts the shipped +// telemetry row in FULL mode against a local dead endpoint (no record leaves +// the process), so the golden pins the shipped default sentence +// `Session sharing is enabled.`; the per-status sentences are pinned by the +// package and OTel unit tests. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/feedback-command', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') +const MODE = webSnapshotMode() +// Discard port: loopback listener never binds, so FULL telemetry discloses +// the shipped default policy without any record reaching a collector. +const TELEMETRY_URL = 'http://127.0.0.1:9/v1/logs' + +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' + +describe('web e2e: /feedback command acknowledgement', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + telemetryUrl: TELEMETRY_URL, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), + }) + 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 }) + // Fresh world: connecting a workspace births the blank session whose + // live composer accepts the slash line. + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('records feedback and renders the acknowledgement with session id and sharing status', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command')) + if (MODE !== 'record') { + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + + // First send the recorded prompt so the transcript is active — a command + // row does not render while a fresh session is still blank. + const input = page.locator('textarea').first() + await input.fill(PROMPT) + await input.press('Enter') + await scaffold.whenTurnSettled() + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + + await input.fill('/feedback the diff view is unreadable') + await input.press('Enter') + // The command plane settles without a model turn: the ack row names the + // recorded session and the mounted FULL backend's disclosure. + await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) + expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a93828282e..772bd4ae91 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -245,6 +245,13 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean + /** + * Mount the shipped telemetry row in FULL mode against this exporter URL + * instead of disabling it. Used to pin a real backend disclosure in + * assembled coverage; point the URL at a local dead endpoint so no record + * leaves the process. + */ + telemetryUrl?: string /** * Browse through a trusted non-loopback hostname that the browser resolves * to loopback (for example `*.localhost`). The test server stays bound to @@ -334,6 +341,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise failures.push(cleanupError)) + restoreSkillRootEnvironment() if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed') throw error } @@ -395,8 +403,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}` followed by `User: {userId}`. | +| `/feedback ` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}`, `User: {userId}`, plus the session-sharing disclosure. | | `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged. +## Session-sharing disclosure + +The acknowledgement names the receiving session id and reports how that session is shared, read from the mounted [`telemetry`](../../session/session-telemetry/README.md) service through the plugin context (`ctx.get('telemetry')`, never a declared injection). The disclosure is one sentence chosen from the backend's [`TelemetrySharingStatus`](../../session/session-telemetry/README.md): + +| Disclosed status | Acknowledgement sentence | +|---|---| +| `full` | `Session sharing is enabled.` | +| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` | +| `disabled` | `Session sharing is disabled.` | +| no service | `Session sharing is not configured.` | + +The disclosure states the deployment's current sharing policy only; it never promises delivery or retention. With `full` or `feedback-only`, records are handed to the backend's non-blocking enqueue and the SDK owns batching, retry, and loss policy, so the sentence claims nothing about what reached a collector; `disabled` claims nothing about future reconfiguration. The disclosure adds no event and never enters the model surface. + ## What this plugin does and does not do `recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) consumer observes the event without changing its capture contract. @@ -56,4 +69,5 @@ Independent of the model request path. Recording appends to the session log only - **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. +- **No visible acknowledgement on a fresh session** — the web transcript renders command rows only once a session is active, so `/feedback` on a still-blank session records the event but shows no acknowledgement row. Recording feedback after the first message renders normally. - **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index ca74d53f25..12a4dcace0 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -8,11 +8,24 @@ | 输入 | 结果 | |---|---| -| `/feedback ` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}` 确认,随后显示 `User: {userId}`。 | +| `/feedback ` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}`、`User: {userId}` 加会话共享披露确认。 | | `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | 前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。 +## 会话共享披露 + +确认文本会点名接收会话的 id,并报告该会话如何被共享;该信息通过插件上下文(`ctx.get('telemetry')`,绝不是声明的注入)从已挂载的 [`telemetry`](../../session/session-telemetry/README.md) 服务读取。披露是依据后端 [`TelemetrySharingStatus`](../../session/session-telemetry/README.md) 选择的一句话: + +| 披露的状态 | 确认文本中的句子 | +|---|---| +| `full` | `Session sharing is enabled.` | +| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` | +| `disabled` | `Session sharing is disabled.` | +| 无服务 | `Session sharing is not configured.` | + +披露只陈述部署当前的共享策略,绝不承诺投递或留存:在 `full` 或 `feedback-only` 下,记录被交给后端的非阻塞入队,批处理、重试与丢失策略归 SDK 负责,因此句子不声称任何内容已到达采集端;`disabled` 也不声称未来不会重新配置。披露不新增任何事件,也绝不会进入模型 surface。 + ## 本插件做什么、不做什么 `recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) 消费方会观察该事件,但不改变它的采集约定。 @@ -56,4 +69,5 @@ - **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。 - **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。 - **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。 +- **新会话上没有可见的确认**:Web 转录只在会话激活后渲染命令行,因此在仍为空白的新会话上执行 `/feedback` 会记录事件但不会显示确认行。发送首条消息后再记录反馈即可正常渲染。 - **随附的产品入口中只有 Web 使用此命令**:无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index b557eb788b..f45d504814 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-user-id": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, @@ -46,6 +47,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-user-id": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 8922df008e..daeee26f79 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -83,6 +83,9 @@ export function recordFeedback(session: Session, text: string): void { * @returns an acknowledgement containing the receiving session and anonymous * user ids plus the session-sharing disclosure, or a usage error when no * feedback text was supplied. + * @returns an acknowledgement containing the receiving session id and the + * session-sharing disclosure, or a usage error when no feedback text was supplied. +>>>>>>> 632abf2957 (feat(feedback): disclose session sharing in the /feedback acknowledgement) */ function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): CommandResult { if (invocation.rawInput.trim().length === 0) { @@ -93,6 +96,8 @@ function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): Co return { kind: 'success', text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}. ${sharingDisclosure(telemetry)}`, + text: `Feedback recorded for session ${invocation.agent.session.id}. ${sharingDisclosure(telemetry)}`, +>>>>>>> 632abf2957 (feat(feedback): disclose session sharing in the /feedback acknowledgement) } } diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 453d9c17fc..ca965bff0d 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -5,6 +5,7 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' +import { Telemetry, type TelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry' import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => { @@ -25,6 +26,20 @@ interface Harness { readonly plugin: Awaited> } +/** Minimal mounted backend disclosing one sharing policy. */ +class FakeTelemetry extends Telemetry { + override readonly sharing: TelemetrySharingStatus + + constructor(ctx: Context, config: { sharing: TelemetrySharingStatus }) { + super(ctx) + this.sharing = config.sharing + } + + emit(): void {} + + async shutdown(): Promise {} +} + /** Build a live idle agent over a store-owned session, as an app's spine does. */ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { const session = ctx.sessions.create(SessionId(id)) @@ -48,12 +63,17 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } return { agent, session } } -/** Mount the real command registry and this producer. */ -async function harness(): Promise { +/** + * Mount the real command registry, this producer, and optionally a telemetry + * backend disclosing one sharing policy. Without `sharing`, no telemetry + * service exists and the acknowledgement reports "not configured". + */ +async function harness(sharing?: TelemetrySharingStatus): Promise { const ctx = new Context() await ctx.plugin(CommandService) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) + if (sharing !== undefined) await ctx.plugin(FakeTelemetry, { sharing }) const plugin = await ctx.plugin(commandFeedback) const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`) ctx.agents.register(agent) @@ -104,7 +124,7 @@ describe('/feedback human command', () => { const test = await harness() await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ kind: 'success', - text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}`, + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) const commandRun = test.session.events.find(event => event.type === 'command/run') @@ -152,12 +172,39 @@ describe('/feedback human command', () => { test.ctx.commands.execute(test.agent, '/feedback second', signal), ]) expect(settled.map(item => item?.result)).toEqual([ - { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` }, - { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.` }, ]) expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) + it('discloses full session sharing in the acknowledgement', async () => { + const test = await harness('full') + await expect(run(test, ' everything shared')).resolves.toEqual({ + kind: 'success', + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is enabled.`, + }) + expect(feedbackTexts(test.session)).toEqual(['everything shared']) + }) + + it('discloses feedback-gated session sharing in the acknowledgement', async () => { + const test = await harness('feedback-only') + await expect(run(test, ' gated sharing')).resolves.toEqual({ + kind: 'success', + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`, + }) + expect(feedbackTexts(test.session)).toEqual(['gated sharing']) + }) + + it('discloses disabled session sharing in the acknowledgement', async () => { + const test = await harness('disabled') + await expect(run(test, ' local only')).resolves.toEqual({ + kind: 'success', + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is disabled.`, + }) + expect(feedbackTexts(test.session)).toEqual(['local only']) + }) + it('keeps every recorded event off the model surface and out of derived history', async () => { const test = await harness() await run(test, ' invisible to the model') diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 2060fe2207..e777f13124 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -93,7 +93,7 @@ describe('/feedback real Loader composition through cordis.yml', () => { const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } }) expect(accepted?.result).toEqual({ kind: 'success', - text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}`, + text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}. Session sharing is not configured.`, }) const rejected = await context.commands.execute(owner, '/feedback', signal) expect(rejected?.result).toEqual({ diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml index 2897eb7dac..161f201cfb 100644 --- a/packages/session/session-telemetry-otel/README.i18n.yaml +++ b/packages/session/session-telemetry-otel/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-telemetry-otel/README.md -README.md: 585995ce409255df9608bc33b76625374bc67669 -README.zh.md: 7f0b93363fbb4aebb80f0d3cc8108e58ce3f647f +README.md: e3eae475a180419c7822d51858ae156052a663d6 +README.zh.md: cfdf36ac5783850cc5e63bbb2b622584f1064b0c diff --git a/packages/session/session-telemetry-otel/README.md b/packages/session/session-telemetry-otel/README.md index 585995ce40..e3eae475a1 100644 --- a/packages/session/session-telemetry-otel/README.md +++ b/packages/session/session-telemetry-otel/README.md @@ -29,6 +29,8 @@ Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`T Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present. +The mounted service discloses the resolved mode through the seam's [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` property (`full` / `feedback-only` / `disabled`), so the `/feedback` acknowledgement can report whether and how the session is shared. The disclosure is set in the constructor and is independent of capture: even `DISABLED` discloses `disabled`. + `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. In uploading modes, `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline that defaults to 3000 ms, and a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit. ## What leaves the machine diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md index 7f0b93363f..cfdf36ac57 100644 --- a/packages/session/session-telemetry-otel/README.zh.md +++ b/packages/session/session-telemetry-otel/README.zh.md @@ -29,6 +29,8 @@ 上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时,会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。 +已挂载的服务通过 seam 的 [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`。 + `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。在上传模式中,`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms;`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。关闭期间,OTel 会先等待 `exporter.forceFlush()`,再等待受处理器 `exportTimeoutMillis` 限制的完成 promise;如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待,通过协调器记录已隔离的关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。 ## 哪些数据会离开本机 diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 5a5102ca51..1f208394ff 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -22,6 +22,7 @@ import { type TelemetryBackend, type TelemetryRecord, type TelemetrySeverity, + type TelemetrySharingStatus, } from '@deepseek-ai/dsh-session-telemetry' import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' @@ -71,6 +72,17 @@ function assertNever(value: never): never { throw new Error(`session-telemetry-otel: unsupported mode ${JSON.stringify(value)}`) } +/** Map the serialized mode onto the seam's backend-independent sharing vocabulary. */ +function sharingStatusFor(mode: TelemetryMode): TelemetrySharingStatus { + switch (mode) { + case TelemetryMode.FULL: return 'full' + case TelemetryMode.FEEDBACK_ONLY: return 'feedback-only' + case TelemetryMode.DISABLED: return 'disabled' + /* v8 ignore next 2 -- resolveMode already rejected unknown values before this switch; the closed enum cannot reach the default. */ + default: return assertNever(mode) + } +} + /** * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint @@ -139,10 +151,12 @@ export class TelemetryOtel extends Telemetry { private readonly directEmit: TelemetryBackend['emit'] private readonly provider: LoggerProvider | undefined private readonly shutdownTimeoutMillis: number + override readonly sharing: TelemetrySharingStatus constructor(ctx: Context, config: Config) { const mode = resolveMode(config.mode) super(ctx) + this.sharing = sharingStatusFor(mode) if (mode === TelemetryMode.DISABLED) { this.directEmit = DROP_RECORD this.provider = undefined diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 3e14bf3c9d..a5bb9d06ae 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -364,6 +364,31 @@ describe('TelemetryOtel wire', () => { expect(captures).toEqual([]) }) + it('discloses the sharing policy for every mode', async () => { + const { url, captures } = await mockCollector() + + const fullCtx = new Context() + await fullCtx.plugin(SessionStore) + const full = await fullCtx.plugin(TelemetryOtel, { exporter: { url } }) + expect(fullCtx.telemetry.sharing).toBe('full') + await full.dispose() + + const gatedCtx = new Context() + await gatedCtx.plugin(SessionStore) + const gated = await gatedCtx.plugin(TelemetryOtel, { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url } }) + expect(gatedCtx.telemetry.sharing).toBe('feedback-only') + await gated.dispose() + + const disabledCtx = new Context() + await disabledCtx.plugin(SessionStore) + const disabled = await disabledCtx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED }) + expect(disabledCtx.telemetry.sharing).toBe('disabled') + await disabled.dispose() + + // No record was emitted by any mode, so nothing reached the collector. + expect(captures).toEqual([]) + }) + it('defaults direct construction to full delivery', async () => { const { url, captures } = await mockCollector() const ctx = new Context() diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml index 3d4650361f..4b3169d5fe 100644 --- a/packages/session/session-telemetry/README.i18n.yaml +++ b/packages/session/session-telemetry/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-telemetry/README.md -README.md: 827554dd53a81eab5a5fd7f145df3f835db9c173 -README.zh.md: a350ea5935a2143cb0f876eeb1eb0520ffee5c53 +README.md: 707dcfcdb0c8dfbd622630351928ac43562535ec +README.zh.md: bd080adceebf83cd9e53d72a7093db376cf6cbd1 diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md index 827554dd53..707dcfcdb0 100644 --- a/packages/session/session-telemetry/README.md +++ b/packages/session/session-telemetry/README.md @@ -8,6 +8,12 @@ The telemetry Service Definition declares the `TelemetryBackend` contract, and i `TelemetryBackend` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `Telemetry` registers this API under the `telemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `TelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger. +The service also carries the required [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` member: the deployment-selected sharing policy every backend must disclose to human-facing acknowledgement surfaces (the `/feedback` command's confirmation). A consumer renders "not configured" only when no telemetry service is mounted. The seam owns the vocabulary (`full` | `feedback-only` | `disabled`) so any backend can disclose a policy without depending on the OTel package. + +## The sharing disclosure + +The acknowledgement of a recorded feedback entry reports whether and how the session is shared, read from the mounted backend's `sharing`. A backend sets the property from its deployment configuration: `full` (every event is handed over as it happens), `feedback-only` (nothing is handed over until a `feedback/record` event releases the unreleased prefix through it), or `disabled` (nothing is handed over at all). Consumers map the status onto user-facing copy; the disclosure never claims delivery — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the backend SDK's. + ## Capture points In `live` mode the coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). In `on-demand` mode it registers only the dispose effect: `captureSession()` reads the canonical log through an optional inclusive sequence boundary, while flush hints and operational events remain local. diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md index a350ea5935..bd080adcee 100644 --- a/packages/session/session-telemetry/README.zh.md +++ b/packages/session/session-telemetry/README.zh.md @@ -8,6 +8,14 @@ `TelemetryBackend` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束,dispose(资源释放)会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`Telemetry` 将此 API 注册在 `telemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 捕获构造 `TelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`。 +该服务还携带必需的 [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` 成员:每个后端都必须向面向用户的确认 surface(`/feedback` 命令的确认文本)披露的部署级共享策略。消费方只有在未挂载任何遥测服务时才渲染「未配置」。seam 拥有该词汇(`full` | `feedback-only` | `disabled`),因此任何后端都可以披露策略,而无需依赖 OTel 包。 + + + +## 共享披露 + +一条已记录的反馈条目的确认文本会报告该会话是否以及如何被共享,读取自已挂载后端的 `sharing`。后端根据其部署配置设置该属性:`full`(每个事件在发生时立即交接)、`feedback-only`(在 `feedback/record` 事件释放其之前的未释放前缀之前,不交接任何内容)或 `disabled`(完全不交接任何内容)。消费方把状态映射为面向用户的文案;披露从不声称投递——交接是非阻塞入队,批处理、重试与丢失策略仍归后端 SDK。 + ## 捕获点 在 `live` 模式中,协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。在 `on-demand` 模式中,协调器只注册 dispose effect:`captureSession()` 读取权威日志,直至可选的序列号边界(含边界);flush 提示与运维事件留在本地。 diff --git a/packages/session/session-telemetry/src/index.ts b/packages/session/session-telemetry/src/index.ts index 19b58d1ee2..0900d9cdff 100644 --- a/packages/session/session-telemetry/src/index.ts +++ b/packages/session/session-telemetry/src/index.ts @@ -130,6 +130,15 @@ export interface TelemetryBackend { shutdown(): Promise } +/** + * Deployment-selected session-sharing policy disclosed by a mounted + * {@link Telemetry} backend to human-facing acknowledgement surfaces (the + * `/feedback` command's confirmation text). The seam owns the vocabulary so + * any backend can disclose a policy without depending on the OTel package; + * the values mirror the OTel backend's serialized `TelemetryMode` choices. + */ +export type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' + /** * Loadable form of the backend contract: one implementation per context — * the cordis `Service` registration under the `telemetry` key throws on a @@ -141,6 +150,15 @@ export abstract class Telemetry extends Service implements TelemetryBackend { super(ctx, 'telemetry') } + /** + * Deployment-selected session-sharing policy, disclosed for acknowledgement + * surfaces that report whether recorded feedback leaves the process. Every + * backend must disclose its policy; a consumer renders "not configured" only + * when no telemetry service is mounted. The seam owns this vocabulary so the + * disclosure is backend-independent. + */ + abstract readonly sharing: TelemetrySharingStatus + /** * See {@link TelemetryBackend.emit} — that declaration is the contract's one home. * @param record - the logical record to report; owned by the backend after the call. diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1a9d998029..c1b1b2753a 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1620,6 +1620,11 @@ "symbol": "WebBootGraph", "source": "packages/client/modules/src/client/manifest.ts" }, + { + "doc": "docs/subsystems/telemetry.md", + "symbol": "TelemetrySharingStatus", + "source": "packages/session/session-telemetry/src/index.ts" + }, { "doc": "docs/subsystems/telemetry.md", "symbol": "TelemetrySeverity", diff --git a/tsconfig.host.json b/tsconfig.host.json index 32ae7df42d..12c5d365d6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -52,6 +52,7 @@ "apps/web/tests/agent-preset-authoring.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/goal-bar.e2e.ts", + "apps/web/tests/feedback-command.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 6a6148a08c103ad321dc72012d22754465e3e830 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 14:53:05 +0800 Subject: [PATCH 41/77] test(feedback): fix the assembled e2e record path and teardown restore The feedback-command e2e now drives the recorded prompt through a separate all-modes test that arms whenTurnSettled before sending and writes the fixture back via recordFixture in record mode; the acknowledgement golden test runs only in replay/refresh. The scaffold restores the pinned DSH_HOME on the persistence-root setup failure path, and the telemetry subsystems page links the README's sharing-disclosure anchor. --- apps/web/tests/feedback-command.e2e.ts | 28 +++++++++++----- .../feedback-command/ack.expected.md | 2 ++ docs/module-graph.i18n.yaml | 4 +-- docs/subsystems/telemetry.i18n.yaml | 4 +-- docs/subsystems/telemetry.md | 32 ++++++++++++++----- docs/subsystems/telemetry.zh.md | 32 ++++++++++++++----- .../command-feedback/README.i18n.yaml | 4 +-- 7 files changed, 76 insertions(+), 30 deletions(-) diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index 6dd8ac19a0..577e77a97a 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -16,7 +16,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -56,20 +56,32 @@ describe('web e2e: /feedback command acknowledgement', () => { await scaffold?.close() }) - it('records feedback and renders the acknowledgement with session id and sharing status', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command')) + it('drives the recorded prompt to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-drive')) if (MODE !== 'record') { + // Drift guard: the committed fixture must carry exactly the drive prompt. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } - - // First send the recorded prompt so the transcript is active — a command - // row does not render while a fresh session is still blank. const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + // Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot + // miss the turn/end that settles the recorded turn. + const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) await input.press('Enter') - await scaffold.whenTurnSettled() - await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 60_000) + it.skipIf(MODE === 'record')('records feedback and renders the acknowledgement with session id and sharing status', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command')) + // The drive test settled the recorded turn: the transcript is active (a + // command row does not render while a fresh session is still blank) and + // the replayed reply is on screen. + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + const input = page.locator('textarea').first() await input.fill('/feedback the diff view is unreadable') await input.press('Enter') // The command plane settles without a model turn: the ack row names the diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index 5e6a769e74..fdc43ad90d 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -1,6 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Reply with the single word" [disabled] + - img + - text: 标准模式 - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1f864cf46d..a5f2d4e167 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: 2218d79e28e835ab96abce96eaf92bbae25e2182 -module-graph.zh.md: 276b70d69c2898d74ac6897e398b02a8944fd503 +module-graph.md: 8dea030a68f5dde3ce072a8f9ae7156ad162967b +module-graph.zh.md: a611ca8300f17b19c5d4f01032dc767dd04743c1 diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index f5cda71a1d..5c8d376079 100644 --- a/docs/subsystems/telemetry.i18n.yaml +++ b/docs/subsystems/telemetry.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/telemetry.md -telemetry.md: 5ea5c67210ce1387cbd886935e914baf7f904fbb -telemetry.zh.md: bd8fc8acc4c8522d8b1e4bc543431c0abf224411 +telemetry.md: 1b34f25361049611483ac9a10b2f90d7dac64439 +telemetry.zh.md: 9d20831d74792944f5b17e43e6ed14f02dc00275 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 5ea5c67210..1b34f25361 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -2,7 +2,7 @@ English | [中文](telemetry.zh.md) -Outbound session reporting is one [capability seam](../capability-seams.md): its Service Definition ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) declares the minimal backend contract, and its capture coordinator owns the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, and handoff cursor; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) uses the OpenTelemetry JS SDK's log pipeline with its configuration unchanged. This optional capability is not part of the agent loop, and nothing here reaches a model request. The harness stops after it calls `emit()`; the reporting SDK owns batching, retry, queueing, and loss policy. The [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records that rule and the rejected alternatives. The [Service Definition README](../../packages/session/session-telemetry/README.md) defines the capture-point, cursor, and projection contracts. +Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) own the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, handoff cursor, and minimal backend contract; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture points, cursor, and projection contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md). Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -56,12 +56,28 @@ interface TelemetryRecord { Only the first `assistant/chunk` of each `(turn, step)` ships — the stream-started signal; the rest drop at capture, so `seq` gaps are routine on the wire and never a loss signal. Every other [session event](session.md) type, including plugin-merged ones the seam never heard of, passes through whole. Delivery is best-effort: the cursor marks handed-off, not delivered, records can be lost (crash, reload window) and duplicated (cursor-less re-adoption, SDK retries), so receivers dedupe ledger records on `(session.id, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead. +## The sharing disclosure + +The seam's acknowledgement contract (owned by the [Service Definition README's sharing-disclosure section](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)): every backend discloses its deployment-selected sharing policy through the required abstract `sharing` member on `ctx.telemetry`, and consumers render "not configured" only when no telemetry service is mounted. The disclosure states the current policy, never delivery or retention — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the reporting SDK's. + +```ts type-equiv +/** + * Deployment-selected session-sharing policy disclosed by a mounted + * {@link Telemetry} backend to human-facing acknowledgement surfaces (the + * `/feedback` command's confirmation text). The seam owns the vocabulary so + * any backend can disclose a policy without depending on the OTel package; + * the values mirror the OTel backend's serialized `TelemetryMode` choices. + */ +type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' +``` + ## The backend contract ```ts type-equiv /** - * The minimum backend contract the coordinator requires. {@link Telemetry} is - * its service-registered form; tests compose the coordinator with a bare + * The backend contract the coordinator hands records to — the minimum any + * reporting SDK satisfies with zero bending. {@link Telemetry} is its + * service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -76,8 +92,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a turn ended. A backend may forward it to its SDK's - * flush so records are exported after each turn. Called + * Optional hint that a natural boundary (turn end) passed — a backend may + * forward it to its SDK's flush so records land at turn boundaries. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -104,7 +120,7 @@ interface TelemetryBackend { } ``` -`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the loadable form of this contract: each context accepts one implementation and throws on a duplicate. A backend constructs `TelemetryCoordinator` in its constructor to install capture. +`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the contract's loadable form — one implementation per context, duplicate load throws — and a backend composes the seam's `TelemetryCoordinator` in its constructor to install the capture side. ## The redact waterfall: `telemetry/record` @@ -122,7 +138,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -141,7 +157,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index bd8fc8acc4..9d20831d74 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -2,7 +2,7 @@ [English](telemetry.md) | 中文 -对外会话上报是一项[能力 seam](../capability-seams.md):其 Service Definition([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)声明最小后端约定,其捕获协调器负责捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)和 handoff 游标;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))按原配置使用 OpenTelemetry JS SDK 日志流水线。这项能力可选,不属于 agent loop(智能体循环),这里也没有任何内容会进入模型请求。Harness 调用 `emit()` 后停止处理;上报 SDK 负责批处理、重试、排队和丢失策略。[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录了这条规则和被否决的替代方案。[Service Definition README](../../packages/session/session-telemetry/README.md) 定义捕获点、游标和投影约定。 +对外的会话上报拆分为一项[能力 seam](../capability-seams.md):Service Definition 与捕获协调器([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)拥有捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)、handoff 游标与最小后端约定;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`;批处理、重试、排队与丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中定案;捕获点、游标与投影的约定见 [Service Definition README](../../packages/session/session-telemetry/README.md)。 源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -56,12 +56,28 @@ interface TelemetryRecord { 每个 `(turn, step)` 只发出第一条 `assistant/chunk`,即「流已开始」的信号;其余分片在捕获时丢弃,因此导出流中的 `seq` 缺口是常态,绝不是丢失信号。其他所有[会话事件](session.md)类型都会完整透传,包括该 seam 从未听说过、由插件合并进来的事件类型。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(无游标的重新接管、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。 +## 共享披露 + +该 seam 的确认契约(归属 [Service Definition README 的共享披露段](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)):每个后端都通过 `ctx.telemetry` 上必需的抽象 `sharing` 成员披露其部署级共享策略,消费方只有在未挂载任何遥测服务时才渲染「未配置」。披露只陈述当前策略,绝不承诺投递或留存——交接是非阻塞入队,批处理、重试与丢失策略仍归上报 SDK。 + +```ts type-equiv +/** + * Deployment-selected session-sharing policy disclosed by a mounted + * {@link Telemetry} backend to human-facing acknowledgement surfaces (the + * `/feedback` command's confirmation text). The seam owns the vocabulary so + * any backend can disclose a policy without depending on the OTel package; + * the values mirror the OTel backend's serialized `TelemetryMode` choices. + */ +type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' +``` + ## 后端约定 ```ts type-equiv /** - * The minimum backend contract the coordinator requires. {@link Telemetry} is - * its service-registered form; tests compose the coordinator with a bare + * The backend contract the coordinator hands records to — the minimum any + * reporting SDK satisfies with zero bending. {@link Telemetry} is its + * service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -76,8 +92,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a turn ended. A backend may forward it to its SDK's - * flush so records are exported after each turn. Called + * Optional hint that a natural boundary (turn end) passed — a backend may + * forward it to its SDK's flush so records land at turn boundaries. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -104,7 +120,7 @@ interface TelemetryBackend { } ``` -`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载类型:每个上下文只允许一个实现,重复加载会抛出异常。后端在构造函数中创建 `TelemetryCoordinator`,以安装捕获处理。 +`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载形态:每个上下文只允许一个实现,重复加载会抛出异常;后端在其构造函数中组合 seam 的 `TelemetryCoordinator`,以此装配捕获侧。 ## 脱敏 waterfall:`telemetry/record` @@ -122,7 +138,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -141,7 +157,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index ea0c591ae2..f199e9f4eb 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: 52b8fb6a423fca69f76397deec36ecd22a6a6023 -README.zh.md: ca74d53f2531a46c2c16aa1423cee52e89c8256f +README.md: 6db5bc4b18815778628d5caa74b75c8b46d3f6a8 +README.zh.md: d9ad1aba9bdbc04110430aa3e3f6604313b8ee20 From ac7c44a5dfebb7cc3f8514d780a13442c2233c55 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 18:33:44 +0800 Subject: [PATCH 42/77] fix(feedback): drop rebase residue from the sharing acknowledgement The post-rebase cleanup removes leftover conflict-marker lines and the superseded acknowledgement text from the command source, re-adds the session-telemetry project reference, and restores the lockfile importer link for the sharing dependency. --- packages/feedback/command-feedback/src/index.ts | 5 ----- packages/feedback/command-feedback/tsconfig.json | 3 +++ pnpm-lock.yaml | 3 +++ 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index daeee26f79..8922df008e 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -83,9 +83,6 @@ export function recordFeedback(session: Session, text: string): void { * @returns an acknowledgement containing the receiving session and anonymous * user ids plus the session-sharing disclosure, or a usage error when no * feedback text was supplied. - * @returns an acknowledgement containing the receiving session id and the - * session-sharing disclosure, or a usage error when no feedback text was supplied. ->>>>>>> 632abf2957 (feat(feedback): disclose session sharing in the /feedback acknowledgement) */ function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): CommandResult { if (invocation.rawInput.trim().length === 0) { @@ -96,8 +93,6 @@ function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): Co return { kind: 'success', text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}. ${sharingDisclosure(telemetry)}`, - text: `Feedback recorded for session ${invocation.agent.session.id}. ${sharingDisclosure(telemetry)}`, ->>>>>>> 632abf2957 (feat(feedback): disclose session sharing in the /feedback acknowledgement) } } diff --git a/packages/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json index c39f55f60f..fe189a9c3e 100644 --- a/packages/feedback/command-feedback/tsconfig.json +++ b/packages/feedback/command-feedback/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../session/user-id" }, + { + "path": "../../session/session-telemetry" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 771706fb80..a268f8e951 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3782,6 +3782,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-telemetry': + specifier: workspace:^ + version: link:../../session/session-telemetry '@deepseek-ai/dsh-user-id': specifier: workspace:^ version: link:../../session/user-id From d9f8270cc3e3ed796572851e02a133a222d292e1 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 18:33:49 +0800 Subject: [PATCH 43/77] docs: sync sharing-disclosure catalogs and module graph after rebase Regenerates the ack golden for the merged acknowledgement format, records the zh counterparts and pairing hashes for the telemetry and catalog pages, and restores the command-feedback to session-telemetry edge and dependency in the module graph. --- .../snapshots/feedback-command/ack.expected.md | 6 ++++-- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- docs/persistence-catalog.i18n.yaml | 2 +- docs/persistence-catalog.md | 2 +- docs/subsystems/telemetry.i18n.yaml | 4 ++-- docs/subsystems/telemetry.md | 13 ++++++------- docs/subsystems/telemetry.zh.md | 13 ++++++------- packages/feedback/command-feedback/README.i18n.yaml | 4 ++-- 12 files changed, 30 insertions(+), 28 deletions(-) diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index fdc43ad90d..9a854ec81a 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -23,8 +23,10 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- img -- text: feedback Feedback recorded for session session-{{uuid}}. Session sharing is enabled. +- 'button "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."': + - img + - img + - text: "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled." - textbox "Message the agent" - button "Commands": - img diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3957d29f57..2f05b260a2 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: 0813c9e1f1d761b69180bc919d0629e10c7661bc +config-catalog.md: 646198cea4d30ddc799ef4886af309f376cfa9f2 config-catalog.zh.md: cda44f7904196fe2bf401fed2dc5b5e8b28ccf1c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0813c9e1f1..646198cea4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1562,7 +1562,7 @@ export enum TelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:79`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a5f2d4e167..71b5e667c7 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: 8dea030a68f5dde3ce072a8f9ae7156ad162967b -module-graph.zh.md: a611ca8300f17b19c5d4f01032dc767dd04743c1 +module-graph.md: aaa75f1578679495555f4169a5b031159a6e3cbb +module-graph.zh.md: 56f76bbac65fd485fcdbb2f9bdeedb52cecc8616 diff --git a/docs/module-graph.md b/docs/module-graph.md index 2218d79e28..cbe1a2d75e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -715,6 +715,7 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_session_telemetry pkg_command_feedback --> pkg_user_id pkg_permission --> pkg_bash pkg_permission --> pkg_commands @@ -1373,7 +1374,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 276b70d69c..3c7511cfd3 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -717,6 +717,7 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_session_telemetry pkg_command_feedback --> pkg_user_id pkg_permission --> pkg_bash pkg_permission --> pkg_commands @@ -1375,7 +1376,7 @@ flowchart TD | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ee0b5cbdd6..5778b13667 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.md: 1b94ecc541f2b9da216a5d10e02a8a5aa46f7cfb persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f44569d3ba..1b94ecc541 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -364,7 +364,7 @@ Source: [`packages/compact/compact/src/types.ts:33`](../packages/compact/compact 'feedback/record': { text: string } ``` -Source: [`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) +Source: [`packages/feedback/command-feedback/src/index.ts:62`](../packages/feedback/command-feedback/src/index.ts) ### `goal/*` diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index 5c8d376079..09caaa6039 100644 --- a/docs/subsystems/telemetry.i18n.yaml +++ b/docs/subsystems/telemetry.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/telemetry.md -telemetry.md: 1b34f25361049611483ac9a10b2f90d7dac64439 -telemetry.zh.md: 9d20831d74792944f5b17e43e6ed14f02dc00275 +telemetry.md: 97694a9a5a209224087d0d8454d83e29ce568ea4 +telemetry.zh.md: 9e8b17f4bddb3debdf4dff9d3c3fed1296ebf3d7 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 1b34f25361..97694a9a5a 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -75,9 +75,8 @@ type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -92,8 +91,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -138,7 +137,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -157,7 +156,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:148`](../../packages/session/session-telemetry/src/index.ts) diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index 9d20831d74..9e8b17f4bd 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -75,9 +75,8 @@ type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' ```ts type-equiv /** - * The backend contract the coordinator hands records to — the minimum any - * reporting SDK satisfies with zero bending. {@link Telemetry} is its - * service-registered form; tests compose the coordinator with a bare + * The minimum backend contract the coordinator requires. {@link Telemetry} is + * its service-registered form; tests compose the coordinator with a bare * implementation of this interface. */ interface TelemetryBackend { @@ -92,8 +91,8 @@ interface TelemetryBackend { */ emit(record: TelemetryRecord): void /** - * Optional hint that a natural boundary (turn end) passed — a backend may - * forward it to its SDK's flush so records land at turn boundaries. Called + * Optional hint that a turn ended. A backend may forward it to its SDK's + * flush so records are exported after each turn. Called * fire-and-forget; implementations must not block and must not throw * meaningfully (the coordinator contains exceptions). Most backends should * leave this unimplemented and let their SDK's own batching cadence govern @@ -138,7 +137,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.telemetry` — `Telemetry` (abstract seam) -The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. +Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. ```ts cordis-catalog /** @@ -157,7 +156,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:149`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:148`](../../packages/session/session-telemetry/src/index.ts) diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index f199e9f4eb..fe49d8e490 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: 6db5bc4b18815778628d5caa74b75c8b46d3f6a8 -README.zh.md: d9ad1aba9bdbc04110430aa3e3f6604313b8ee20 +README.md: 24a975476b6783b439d4ec94c449f2acbe0b432f +README.zh.md: 12a4dcace001442351916b17fca0d7e2f2c76245 From 806f6d625f97662d82331f7014d3049a0eb67041 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 10 Aug 2026 23:11:29 +0800 Subject: [PATCH 44/77] fix(web): accept sharing disclosure suffix in seeded-history feedback test The /feedback acknowledgement now appends a sharing-policy sentence after the anonymous user id. The seeded-history e2e regex anchored on the end of the User line, and the golden snapshot did not include the disclosure. Update both to match the new format, and re-record the module-graph translation-pairing hash after rebasing onto master (which picked up the windows-native ACL coverage fix in #2182). --- apps/web/tests/seeded-history.e2e.ts | 4 ++-- .../tests/snapshots/seeded-history/feedback-row.expected.md | 6 +++--- docs/module-graph.i18n.yaml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index a112933f9c..9a521a9d48 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -468,9 +468,9 @@ describe('web e2e: seeded history renders through cold resume', () => { if (done?.type !== 'command/done') throw new Error('feedback command did not settle') const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? [] expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`) - expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\./i) expect(extraLine).toBeUndefined() - const userId = userLine?.slice('User: '.length) + const userId = userLine?.match(/^User: ([0-9a-f-]+)/i)?.[1] if (userId === undefined) throw new Error('feedback command omitted the user id') const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md index 87b763d37c..6928b95777 100644 --- a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md @@ -38,10 +38,10 @@ - text: Context injection AGENTS.md - img - text: permission preset read-only -- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]': +- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." [expanded]': - img - - text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" -- text: "Feedback recorded for session {{seededId}} User: {{uuid}}" + - text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." +- text: "Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." - textbox "Message the agent" - button "Commands": - img diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 71b5e667c7..91e49bbc86 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: aaa75f1578679495555f4169a5b031159a6e3cbb -module-graph.zh.md: 56f76bbac65fd485fcdbb2f9bdeedb52cecc8616 +module-graph.md: 1cc8764c34e01386c4d8ce9a66198bcfa6ece1e7 +module-graph.zh.md: 508d2f4789aa2d50b22efc35254aa20fb031d5e5 From 725f0639ef089c3360cca420159418a7968f5036 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 00:50:56 +0800 Subject: [PATCH 45/77] ci: retrigger after rebase onto master From 4786b3be89cde0448fd777db2593274e7d06e85c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 01:05:53 +0800 Subject: [PATCH 46/77] ci: trigger From 893228b19063e16f84b47d0e1a2040fc9dc1126b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 01:32:32 +0800 Subject: [PATCH 47/77] test(feedback): refresh ack golden for master banner locale --- apps/web/tests/snapshots/feedback-command/ack.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index 9a854ec81a..89d40acb3b 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -2,7 +2,7 @@ - navigation "Session hierarchy": - button "Reply with the single word" [disabled] - img - - text: 标准模式 + - text: Standard mode - tablist: - tab "Chat" [selected] - tab "Trajectory" From bb40c2b07936ee6be4884f20f8c2093e884e1ecb Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 11 Aug 2026 11:02:05 +0800 Subject: [PATCH 48/77] docs: re-record module-graph translation pairing after rebase --- docs/module-graph.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 91e49bbc86..e0a5822779 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: 1cc8764c34e01386c4d8ce9a66198bcfa6ece1e7 -module-graph.zh.md: 508d2f4789aa2d50b22efc35254aa20fb031d5e5 +module-graph.md: cbe1a2d75ef33c44b31ac3b84bb9a54def97d0e5 +module-graph.zh.md: 3c7511cfd391ec69548588df3ab597457a420334 From 1ac522ab5a5c0e7dcd636ff23d0ccf3b439f9714 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Tue, 11 Aug 2026 11:08:14 +0800 Subject: [PATCH 49/77] docs(web): clarify workspace picker accessibility state --- .../2026-08-07-workspace-picker-composer-entry.i18n.yaml | 4 ++-- .../feature/2026-08-07-workspace-picker-composer-entry.md | 2 +- .../feature/2026-08-07-workspace-picker-composer-entry.zh.md | 2 +- packages/client/ui-conversation/src/client/contract/slots.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml index 166af490b6..0d52923b6e 100644 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.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-07-workspace-picker-composer-entry.md -2026-08-07-workspace-picker-composer-entry.md: f4b619d4fb56c889e765b613fefe9ea24bff7112 -2026-08-07-workspace-picker-composer-entry.zh.md: 6c264ab89f9c7b0378b23343999a861aa8f0714f +2026-08-07-workspace-picker-composer-entry.md: dc9c26c291de6e7614ace3c787030c0032a9740d +2026-08-07-workspace-picker-composer-entry.zh.md: 585c59b12392823a7388ab7a635a864bd4108929 diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md index f4b619d4fb..dc9c26c291 100644 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md @@ -10,7 +10,7 @@ The [session-scope decision](../architecture/2026-07-25-web-client-session-scope ## Decision -While no Workspace owns the new Session, the whole composer card activates the existing `conversation.hero.workspace` picker by pointer click — the card owns the click handler and its disabled controls let pointer events fall through, so the full capsule is one target — and the read-only resident textarea does the same by Enter or Space, exposing menu expansion state through `aria-haspopup` and `aria-expanded`. A dashed l4 stroke (an SVG dash ring, since native `dashed` has a fixed pattern) with a business-blue hover marks the card as the pick affordance. The card contains `pointerdown`, so the open picker's outside-close cannot race the click's reopen — that close-then-open flickered the chip's expansion echo. Message submission, command, permission, model, and other Session-scoped controls remain locked until Workspace selection creates or reconnects a real Session. +While no Workspace owns the new Session, the whole composer card activates the existing `conversation.hero.workspace` picker by pointer click — the card owns the click handler and its disabled controls let pointer events fall through, so the full capsule is one target — and the read-only resident textarea does the same by Enter or Space. `aria-haspopup="menu"` and `aria-expanded` describe the shared picker menu while it is mounted. On a fresh installation with no Workspace rows, the picker immediately hands off to the directory dialog and clears its expanded state; that dialog exposes its own accessibility semantics. A dashed l4 stroke (an SVG dash ring, since native `dashed` has a fixed pattern) with a business-blue hover marks the card as the pick affordance. The card contains `pointerdown`, so the open picker's outside-close cannot race the click's reopen — that close-then-open flickered the chip's expansion echo. Message submission, command, permission, model, and other Session-scoped controls remain locked until Workspace selection creates or reconnects a real Session. Workspace selection retains the existing owner and flow. `ConversationRoot` opens the picker, `WorkspacePicker` lists or creates the Workspace, and the same textarea DOM node becomes the editable composer after the Session arrives. diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md index 6c264ab89f..585c59b123 100644 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -新会话尚未归属任何 Workspace 时,整张输入卡片都可通过鼠标点击激活现有的 `conversation.hero.workspace` 选择器——点击处理器归卡片所有,其禁用控件放行指针事件,因此整个胶囊是同一个目标;只读的常驻 textarea 也可经 Enter 或 Space 激活,并通过 `aria-haspopup` 和 `aria-expanded` 暴露菜单展开状态。虚线 l4 描边(SVG dash ring,因为原生 `dashed` 的间距不可调)配合 hover 时的 business 蓝,把卡片标记为选择入口。卡片会拦下 `pointerdown`,使已打开选择器的外点关闭无法与点击的重新打开竞态——先关后开会让 chip 的展开回显闪动。消息提交、命令、权限、模型及其他 Session 作用域控件会保持锁定,直到用户选择 Workspace 并创建或重新连接真实 Session。 +新会话尚未归属任何 Workspace 时,整张输入卡片都可通过鼠标点击激活现有的 `conversation.hero.workspace` 选择器——点击处理器归卡片所有,其禁用控件放行指针事件,因此整个胶囊是同一个目标;只读的常驻 textarea 也可经 Enter 或 Space 激活。`aria-haspopup="menu"` 和 `aria-expanded` 在共享选择器菜单挂载时描述其展开状态。全新安装没有 Workspace 行时,选择器会立即转交目录对话框并清除自身的展开状态;该对话框使用自己的可访问性语义。虚线 l4 描边(SVG dash ring,因为原生 `dashed` 的间距不可调)配合 hover 时的 business 蓝,把卡片标记为选择入口。卡片会拦下 `pointerdown`,使已打开选择器的外点关闭无法与点击的重新打开竞态——先关后开会让 chip 的展开回显闪动。消息提交、命令、权限、模型及其他 Session 作用域控件会保持锁定,直到用户选择 Workspace 并创建或重新连接真实 Session。 Workspace 选择继续使用现有 owner 和流程。`ConversationRoot` 打开选择器,`WorkspacePicker` 列出或创建 Workspace;Session 到达后,同一个 textarea DOM 节点变为可编辑状态。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 8917fb431b..602e54d5d3 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -385,7 +385,7 @@ export interface ComposerBarOwnerProps { * its normal DOM so the Workspace pick transitions in place. */ disabled?: boolean - /** Whether the Workspace picker opened from this bar is currently expanded. */ + /** Whether the shared Workspace picker menu is expanded, regardless of which trigger opened it. */ workspacePickerOpen?: boolean /** Open the existing Workspace picker from the inert textarea. */ onRequestWorkspace?: () => void From 34abf697306592177c493fea8b7043a864718c82 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 15:20:52 +0800 Subject: [PATCH 50/77] cleanup: remove managed source installer --- ...-07-29-dsh-source-launch-tsx-esm.i18n.yaml | 4 +- .../2026-07-29-dsh-source-launch-tsx-esm.md | 2 +- ...2026-07-29-dsh-source-launch-tsx-esm.zh.md | 2 +- ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 4 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 4 +- ...-23-personal-staging-maintenance-skills.md | 35 -- ...-personal-staging-maintenance-skills.zh.md | 35 -- ...30-generated-third-party-notices.i18n.yaml | 4 +- ...026-07-30-generated-third-party-notices.md | 4 +- ...-07-30-generated-third-party-notices.zh.md | 4 +- ...staller-adopts-existing-checkout.i18n.yaml | 6 - ...7-31-installer-adopts-existing-checkout.md | 51 --- ...1-installer-adopts-existing-checkout.zh.md | 51 --- ...e-run-without-managed-installer.i18n.yaml} | 6 +- ...10-source-run-without-managed-installer.md | 31 ++ ...source-run-without-managed-installer.zh.md | 31 ++ README.i18n.yaml | 4 +- README.md | 29 +- README.zh.md | 29 +- THIRD_PARTY_NOTICES.md | 2 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 4 +- apps/cli/README.zh.md | 4 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 10 +- apps/cli/reference/README.zh.md | 10 +- apps/cli/tests/install-script.spec.ts | 140 ------ apps/cli/tests/source-launch.compat.spec.ts | 4 +- bin/dsh | 26 -- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 2 +- docs/api-gateway.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- scripts/gen-third-party-notices.ts | 8 +- scripts/install.sh | 425 ------------------ .../request-response.expected.json | 4 +- skills/dsh-customize/SKILL.md | 37 -- skills/dsh-upgrade/SKILL.md | 48 -- skills/dsh-upstream-customization/SKILL.md | 27 -- 42 files changed, 138 insertions(+), 975 deletions(-) delete mode 100644 .agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md delete mode 100644 .agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md delete mode 100644 .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml delete mode 100644 .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md delete mode 100644 .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md rename .agents/notes/implemented/{process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml => simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml} (53%) create mode 100644 .agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md create mode 100644 .agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md delete mode 100644 apps/cli/tests/install-script.spec.ts delete mode 100755 bin/dsh delete mode 100755 scripts/install.sh delete mode 100644 skills/dsh-customize/SKILL.md delete mode 100644 skills/dsh-upgrade/SKILL.md delete mode 100644 skills/dsh-upstream-customization/SKILL.md diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index f4fb753e79..a0d0c2adf9 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.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-29-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: 5cf4a227f388a1ac8315594af4e0256864ef17f5 -2026-07-29-dsh-source-launch-tsx-esm.zh.md: b5a52b3d01840337c0091310e50d8fac34245519 +2026-07-29-dsh-source-launch-tsx-esm.md: e1016e726c32c27104b7cfe258c412502c277948 +2026-07-29-dsh-source-launch-tsx-esm.zh.md: 2dcb7f1c24f4f73d2c259e8d44ca0f63226bdbaf diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index 5cf4a227f3..e1016e726c 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -14,7 +14,7 @@ Startup latency also mattered: the off-thread `module.register()` hooks worker s ## Decision -The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. `bin/dsh`, the root `dsh`/`demo:tui`/`demo:web` scripts, and the Code Mode TUI overlay use the same vector; `bin/dsh` references the hook and tsconfig by absolute checkout paths (bare `tsx/esm` does not resolve from an arbitrary cwd) and pins `TSX_TSCONFIG_PATH` to the root tsconfig. The CJS hook stays off because the CLI source graph is ESM-only; measured TUI time-to-banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain. +The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh`/`demo:headless`/`demo:web` scripts use the same vector from the repository root. The CJS hook stays off because the CLI source graph is ESM-only; measured TUI time-to-banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain. `scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-tasks` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; since fixed.) diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index b5a52b3d01..2dcb7f1c24 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决策 -`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。`bin/dsh`、根目录的 `dsh`/`demo:tui`/`demo:web` 脚本以及 Code Mode TUI overlay 使用同一向量;`bin/dsh` 以 checkout 的绝对路径引用钩子与 tsconfig(裸的 `tsx/esm` 无法从任意 cwd 解析),并将 `TSX_TSCONFIG_PATH` 固定到根 tsconfig。CJS 钩子保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 +`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh`/`demo:headless`/`demo:web` 脚本从仓库根目录使用同一启动方式。CJS 钩子保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 `scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader「仅为已声明运行时依赖映射 workspace import」的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest(元数据清单)走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;后已修复。) diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index a08966f790..57af8f4ed0 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: 02883c89f27e51d6091d4d65167ebdd6a96f6f51 -2026-07-20-dsh-cli-personal-config.zh.md: 6e56e892cf682ea514b036750e44dbb06e944a80 +2026-07-20-dsh-cli-personal-config.md: 4d2ed7de3357a3eb54f066d3ba4eff2fb38a1a4a +2026-07-20-dsh-cli-personal-config.zh.md: 14964beecfcd7fcd699236cb7fce4c8cc7684514 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 02883c89f2..4d2ed7de33 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -14,7 +14,7 @@ The entry modes and the personal file's name and location below are superseded b Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443): -**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` is the product-assembly tier over `packages/*` libraries. One bin dispatches the default interactive TUI, `-p`/`--prompt` headless turns, and the `web` surface. The TUI boots `examples/tui-agent/cordis.yml` (or `--config`) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the app with tsx's ESM hook; the [source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns that contract. `pnpm run demo:tui` runs the same entry. +**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` is the product-assembly tier over `packages/*` libraries. One bin dispatches the default interactive TUI, `-p`/`--prompt` headless turns, and the `web` surface. The TUI boots `examples/tui-agent/cordis.yml` (or `--config`) with the invoking directory as the workspace. From a source checkout, the root `pnpm run dsh` script runs the same entry with tsx's ESM hook; the [source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns that contract. **Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI, Web, and headless surfaces consume its two optional files; the demo bins boot their committed trees verbatim: @@ -40,7 +40,7 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Consequences -- `dsh` from any directory (and `pnpm run demo:tui`) can apply personal providers, models, installed bundle entries, and other Loader entries with no checkout edit; verified end-to-end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. +- An installed `dsh` command can run from any directory, while source users invoke `pnpm run dsh` from the checkout; both can apply personal providers, models, installed bundle entries, and other Loader entries with no checkout edit. The behavior was verified end to end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. - Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../../../../apps/cli/README.md#profiles) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 6e56e892cf..14964beecf 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -14,7 +14,7 @@ Status: implemented 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: -**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。已提交的 `bin/dsh` 启动器通过自身真实路径解析 checkout,并使用 tsx 的 ESM hook 运行应用;该约定由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。`pnpm run demo:tui` 运行同一入口。 +**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。在源码检出中,根目录的 `pnpm run dsh` 脚本使用 tsx 的 ESM hook 运行同一入口;该约定由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。 **个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: @@ -40,7 +40,7 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Consequences -- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、已安装组合包的配置项和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 +- 已安装的 `dsh` 命令可从任意目录运行,源码用户则从 checkout 调用 `pnpm run dsh`;两者都无需修改 checkout 即可应用个人提供方、模型、已安装组合包的配置项和其他 Loader 配置项。该行为已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 - 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../../../../apps/cli/README.md#profiles)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md deleted file mode 100644 index a7ccc5b1e0..0000000000 --- a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Personal staging maintenance skills - -Status: implemented - -English | [中文](2026-07-23-personal-staging-maintenance-skills.zh.md) - -## Problem - -Personal dsh customizations need a repeatable way to locate the installed source, isolate task work, serialize integration, and incorporate upstream changes without rewriting the checkout used by running sessions. User-local instructions solve this for one installation but cannot guide other users or remain synchronized with repository installer behavior. - -## Decision - -The repository distributes [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md), [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md), and [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md) from its root `skills/` directory. Their descriptions name both the operation and user requests that select it. The shipped TUI supplies that directory to the local skill provider at startup, below project and user roots in discovery priority. The workflows derive the active checkout and staging branch from the installed launcher rather than a user-specific path or branch name, defer to repository-local instructions, require task worktrees, and serialize staging mutations with the staging worktree's established `.agents/merge.lock`. - -Before rebasing, an upgrade inspects the Git log and commit ranges to identify incoming upstream changes, personal commits, duplicates, and likely conflicts. It drops customizations already supplied upstream; when only a documentary local diff remains for such a customization, it also drops that account unless it adds an independently useful current contract absent upstream. Each attempt uses one UTC basic timestamp for its independent `dsh-staging-` sibling clone, local `dsh-upgrade/prepare-` branch, new `dsh-staging/` branch, private upstream and recovery refs, and launcher backup. The sibling name does not derive from the current directory name, and collisions fail rather than acquiring ad hoc suffixes. The workflow derives the current DSH process source from the process command and runtime environment rather than the shell working directory, then treats the repository and checkout behind the installed launcher as immutable except for holding its existing merge lock. - -After validation in the independent clone, the workflow creates and verifies the timestamped staging branch, then atomically moves the launcher once from the unchanged old staging checkout to the new staging checkout. The launcher never targets a preparation, feature, review, publication, or detached checkout. Failure before cutover leaves the installed checkout and launcher unchanged; failure after cutover restores and verifies the launcher backup. The old staging checkout, its branch, the recovery ref, and the launcher backup remain available until a restarted process proves that DSH runs from the new staging branch and the user explicitly approves rollback cleanup. - -`dsh-upstream-customization` owns upstream publication independently from local maintenance and upgrades. It recommends bug fixes, additive non-conflicting plugin features, and visual improvements; intrusive changes require maintainer approval first. At the end of an upgrade, the agent classifies remaining customizations, explains their upstream value, recommends whether to propose each one, and asks which named candidate the user wants to upstream. Only that selection loads the publication workflow; each feature still requires explicit approval before a push or draft PR. Approved changes start from current upstream `master` without unrelated personal commits. Draft PRs for TUI features preferably include a screenshot from the assembled application after credentials and personal data are removed. `dsh-customize` requires interactive TUI behavior to be exercised in a dedicated tmux session before integration. - -## Alternatives considered - -**Keep the workflows user-scoped.** This preserves personal flexibility but prevents other users from discovering the same safety rules and lets the workflow drift from the installer shipped by the repository. - -**Rebase the active staging checkout in place.** This is simpler but changes many files during preparation, can disrupt new dsh launches, and cannot provide atomic publication or an unchanged rollback checkout. - -**Update the existing staging checkout after moving the launcher elsewhere.** This retains one staging path but requires a mid-upgrade launcher target that is not a staging branch and still rewrites a checkout that may host a running process. - -**Lock only the final branch switch.** This shortens lock duration but permits a customization merge against the old base while the rebase is being prepared, invalidating the prepared history. - -**Open one upstream PR for all personal changes.** This reduces branch management but publishes unrelated customizations and removes the user's per-feature approval boundary. - -## Consequences - -Upgrade preparation holds the installed staging merge lock while dependencies and checks run, so local customization integration waits for a consistent result. One upgrade creates an independent timestamped clone and staging branch, performs one atomic launcher cutover, and requires one restart afterward; it never writes into the repository or checkout behind the launcher except to hold its existing lock. Each workflow records preconditions, repeats them before mutation, inspects state after interrupted mutations, restores the launcher backup on cutover failure, reruns failed checks after correction, and reports final state. The old staging checkout remains rollback storage until explicit user-approved cleanup. Checked-in evaluations cover selection, process-source protection, unsafe repository states, rollback, and publication authorization; repository documentation checks validate skill links and formatting, while technical review remains responsible for Git and filesystem correctness. diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md b/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md deleted file mode 100644 index 8593291f51..0000000000 --- a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: 个人集成分支维护 skill - -Status: implemented - -[English](2026-07-23-personal-staging-maintenance-skills.md) | 中文 - -## 问题 - -个人 dsh 定制需要一套可重复执行的方法,用于定位已安装的源码、隔离各项任务的修改、串行集成变更,并在不改写运行中会话所用检出的前提下合入上游变更。用户本地指令能解决某一套安装中的问题,却无法指导其他用户,也无法持续与仓库安装脚本的行为保持同步。 - -## 决策 - -仓库从其根 `skills/` 目录分发 [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md)、[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md) 和 [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md)。它们的描述同时说明操作内容和选择该 skill(技能)的用户请求。分发的 TUI 在启动时将该目录提供给本地 skill 提供方,在发现优先级上位于项目根目录和用户根目录之后。这些 skill 根据已安装的启动器而非个人路径或分支名称定位当前生效的检出和集成分支,遵从仓库内指令,要求使用任务 worktree,并利用集成分支所在 worktree 的既有 `.agents/merge.lock`,串行执行每一次个人集成分支修改。 - -升级流程在变基前检查 Git 日志和提交范围,以识别将进入升级的上游变更、个人提交、重复内容和可能发生冲突的区域。它会丢弃上游已经提供的定制;如果这类定制在本地只剩文档差异,也会一并丢弃,除非该说明包含上游缺失且可独立使用的当前约定。每次升级尝试使用同一个 UTC 基本格式时间戳,用于其独立的 `dsh-staging-` 同级克隆、本地 `dsh-upgrade/prepare-` 分支、新的 `dsh-staging/` 分支、私有的上游引用与恢复引用,以及启动器备份。同级克隆的名称不派生自当前目录名,名称冲突会直接失败,而不是追加临时后缀。流程根据进程命令和运行时环境而非 shell 工作目录推导当前 DSH 进程的源码位置,随后将已安装启动器所指向的仓库和检出视为不可变,唯一例外是持有其既有合并锁。 - -在独立克隆中验证通过后,工作流会创建并验证带时间戳的集成分支,然后以原子方式将启动器从保持不变的旧集成分支检出一次性切换到新集成分支检出。启动器绝不会指向准备、功能、评审、发布或处于分离状态的检出。切换前的失败会让已安装的检出和启动器保持不变;切换后的失败则恢复并验证启动器备份。旧的集成分支检出、其分支、恢复引用和启动器备份会一直保留,直到重启后的进程证明 DSH 运行于新的集成分支,且用户明确批准回滚清理为止。 - -`dsh-upstream-customization` 独立于本地维护和升级,负责向上游发布。它推荐 bug 修复、附加式且不冲突的插件功能,以及视觉改进;侵入式变更需先取得维护者批准。在升级结束时,agent 会对剩余定制进行分类、说明其上游价值、建议是否提交,并询问用户希望向上游贡献哪个具名候选项。只有用户做出选择后才会加载发布工作流;每项功能在推送或创建草稿 PR(Pull Request)前仍必须得到明确批准。获批的变更均以当前上游 `master` 为起点,不带入无关的个人提交。TUI 功能的草稿 PR 建议在移除凭证与个人数据后,附上组装后应用的截图。`dsh-customize` 要求在集成前于专用 tmux 会话中检验交互式 TUI 行为。 - -## 备选方案 - -**将这些工作流限定在用户本地。** 这样可以保留个人使用的灵活性,但其他用户无法发现同一套安全规则,工作流也可能逐渐偏离仓库分发的安装脚本行为。 - -**在当前集成分支检出中原地变基。** 此方案更简单,但准备期间会修改大量文件,可能干扰新的 dsh 启动,也无法实现原子发布或提供一份保持不变的回滚检出。 - -**在将启动器迁往别处后更新现有的集成分支检出。** 此方案可以保留单一的集成分支路径,却要求在升级中途让启动器指向一个并非集成分支的目标,且仍会改写可能承载运行中进程的检出。 - -**只在最终切换分支时加锁。** 这样可以缩短持锁时间,却允许写入方在变基准备期间继续基于旧基线合并定制变更,导致准备好的历史失效。 - -**用一个上游 PR 发布所有个人变更。** 这会减少分支管理工作,却会发布无关的定制,并取消用户按功能逐项批准的边界。 - -## 影响 - -升级准备流程在安装依赖和运行检查期间持有已安装集成分支的合并锁,因此本地定制的集成必须等待一致的结果。一次升级会创建独立的带时间戳的克隆和集成分支,执行一次原子的启动器切换,并在切换后要求重启一次;除持有其既有锁之外,升级绝不会写入启动器所指向的仓库或检出。各工作流会记录前置条件、在修改前重复检查、在修改被中断后检查状态、在切换失败时恢复启动器备份、修复后重新运行失败的检查,并报告最终状态。旧的集成分支检出会作为回滚存储一直保留,直到用户明确批准清理为止。仓库内评估覆盖 skill 选择、进程源码保护、不安全的仓库状态、回滚和发布授权;仓库文档检查会验证 skill 的链接和格式,Git 与文件系统操作的正确性仍由技术评审负责。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index b60f3ae3ed..6f6823e3c3 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.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-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: 58e3fd5007a16d6391956f423d1ec91e851df295 -2026-07-30-generated-third-party-notices.zh.md: 834ec4ebc349bdee37b0873109de566223bb7d48 +2026-07-30-generated-third-party-notices.md: d13eef8412ecd5c8387a7239b812c835671b6bce +2026-07-30-generated-third-party-notices.zh.md: 0af6a6bcd665197acd6a0b5e9e6f51940a0267e7 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 58e3fd5007..d13eef8412 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -20,9 +20,9 @@ One trigger gap is accepted rather than worked around: lefthook inspects only fi The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail. -**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the `bin/dsh` launcher execs through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly). +**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the root source-run scripts execute through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly). -The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. `scripts/install.sh` installs the repository itself, so a user's `cordis.yml` can mount any plugin package; `@modelcontextprotocol/sdk` and the OpenTelemetry packages reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. +The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. Source execution can mount any plugin package from a user's `cordis.yml`; `@modelcontextprotocol/sdk` and the OpenTelemetry packages therefore reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. The manifest set is derived from the `packages:` members the root `pnpm-workspace.yaml` declares, including the Landlock workspace and its public packages, so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the root workspace's installed pnpm store and package-local link farms, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 834ec4ebc3..0af6a6bcd6 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -20,9 +20,9 @@ Status: implemented 文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。 -**分层依据是声明方所在区域,而非 manifest 字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一 manifest——即根 manifest、`packages/support/`、`packages/client/test-runtime/`、`website/`、`examples/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而 `bin/dsh` 启动器通过 `tsx` 执行,根本没有任何 manifest 把它声明为运行时依赖,只能由生成器显式标记。 +**分层依据是声明方所在区域,而非 manifest 字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一 manifest——即根 manifest、`packages/support/`、`packages/client/test-runtime/`、`website/`、`examples/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而根目录的源码运行脚本通过 `tsx` 执行,根本没有任何 manifest 把它声明为运行时依赖,只能由生成器显式标记。 -运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` 可以挂载任何插件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 +运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。从源码运行时,用户可以通过 `cordis.yml` 挂载任何插件包;因此,`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 manifest 集合由根 `pnpm-workspace.yaml` 声明的 `packages:` 成员派生,其中包括 Landlock 工作区及其公开包,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自根工作区已安装的 pnpm store 和包本地链接场;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布 manifest 答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列入运行时表格,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml deleted file mode 100644 index 1c58887b2b..0000000000 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: 1d45a14273095610e3ed8047da2ce9f4ca95cbb6 -2026-07-31-installer-adopts-existing-checkout.zh.md: 971bc3b389c341b314872b8e45ab20ebd2ed5b2c diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md deleted file mode 100644 index 1d45a14273..0000000000 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: the installer adopts an existing checkout into the managed layout - -Status: implemented - -English | [中文](2026-07-31-installer-adopts-existing-checkout.zh.md) - -## Problem - -`scripts/install.sh` produced two incompatible installation layouts. A `curl … | sh` install built the managed layout — a master clone at `~/.dsh/source/master`, a staging worktree on `dsh-staging/`, and the stable `current` symlink the PATH launcher resolves through. Running the same script from a checkout instead linked `dsh` straight at that checkout's `bin/dsh`, per the earlier [in-repo skip-clone decision](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md). - -The direct link cannot be upgraded. `current` is what an upgrade repoints, so an install without it is not upgradable by [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md); the PATH symlink dangles if the checkout moves; and the launcher resolves to whatever branch the contributor happened to have checked out, which the upgrade contract forbids as a launcher target. The upgrade skill already described this layout as a legacy install needing a one-time migration, so the layouts diverged at install time and were reconciled only later, if ever. - -## Decision - -In-repo mode still never clones and never modifies the working tree, but it now **adopts** the checkout into the managed layout unconditionally. There is no opt-out: one layout serves every install. - -The container owns staging worktrees and `current`; the repository is *discovered*, not owned. `git rev-parse --git-common-dir` resolves the shared git directory behind the checkout — for a linked worktree that is the real clone rather than the worktree itself — and its parent is the repository that serves as the upgrade base. A staging worktree branched from the checkout's `HEAD` is then created under `$DSH_SOURCE`, and `current` points at it. A clone anywhere on disk therefore converges on the same layout as a `curl` install, and the two paths share one worktree/exclude/lock/link sequence: they differ only in whether the repository was discovered by `git clone` or by `git rev-parse`. - -The installer records nothing about where that repository lives. A container whose repository sits outside it is not self-contained — each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them — but git already owns that fact: the worktree's `.git` file names the path, and `git worktree list` in the clone enumerates every worktree depending on it. - -Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout. This is not prompted or warned about: the installer builds the layout and gets out of the way. Setting `DSH_SOURCE` to a different directory remains the one documented way to opt back into cloning a separate tree. - -Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. Git always reports resolved paths, so comparing one against an unresolved path disagrees whenever a symlink sits anywhere above the checkout — a symlinked home directory is enough, and macOS reaches every `mktemp` path that way through `/var` -> `private/var`. The mismatch misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect class recurs whenever one side of a comparison is left unresolved — as it did with a curl install's `REPO_ROOT` and with the container path it was compared against. `resolve_dir` therefore echoes a missing path back rather than failing, so a not-yet-created container needs no per-call fallback and no site can compare against an empty path by forgetting one; callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+. - -Before `current` is repointed, the installer rejects a staging path that resolves to the repository itself, enforcing the upgrade contract that the launcher never resolves to the master clone. - -## Alternatives considered - -**Make `~/.dsh/source/master` a symlink to the arbitrary clone.** Rejected. Git resolves the symlink and records the *real* path: a worktree created through it stores `gitdir: …//.git/worktrees/`, and `git worktree list` reports the clone. The symlink is therefore decorative — nothing reads it — while implying the container owns the repository. It also fails silently: moving the clone leaves `master` present but dangling and every staging worktree dead with `fatal: not a git repository`. Worst, it aliases two names onto one tree, so the "current must never be the master clone" check passes by string comparison while being false. `~/.dsh/source/master` is a location, not a name, and only the location is authoritative. - -**Promote the checkout itself to the `current` target.** Rejected: the upgrade contract requires `current` to point to a clean staging worktree on a staging branch, never a feature, review, or detached checkout. It would also make every upgrade rewrite the tree the contributor is editing. - -**Keep link-in-place behind a prompt or a `DSH_ADOPT` flag.** Rejected, and an earlier revision of this change shipped exactly that before it was removed. The second layout was the defect itself, so retaining it as an option preserves the problem and doubles the states every later change must handle — the prompt, the flag, the dirty-tree warning, and a second linking path all existed only to keep a layout nothing should produce. The original motivation for link-in-place, keeping the script testable against local source, survives adoption: a staging worktree branched from the checkout's `HEAD` runs the same code. `DSH_SOURCE` remains available for installing a separate tree. - -**Warn or prompt when the tree is dirty.** Rejected: `worktree add` from `HEAD` cannot carry uncommitted work, so the behavior is determined and a prompt only adds a decision the user cannot act on differently. The contract is documented instead. - -**Put an adopted clone's staging worktrees beside the clone** (`~/src/staging-*`) rather than in `~/.dsh/source`. Rejected: `current` and the PATH launcher are per-user singletons, so scattering worktrees across clone parents reintroduces the sibling-clone sprawl the source container exists to prevent. - -## Consequences - -One layout now serves every install, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described, and the installer has no branch that produces an unupgradable layout. In-repo runs still never mutate the working tree. - -The cost is that a contributor can no longer point PATH at a checkout and have `dsh` follow that working tree as they switch branches: the launcher now resolves to a staging worktree pinned to the `HEAD` adopted at install time. Re-running the installer adopts the current `HEAD` again. - -A container adopting an outside clone is also no longer self-contained: deleting that clone breaks its staging worktrees. This is inherent to reusing an existing clone rather than a property of this design — the rejected symlink hides it rather than fixing it — and git's own worktree records are what diagnose it. - -## Testing - -`scripts/install.sh` now has a real-shell PTY regression suite in `apps/cli/tests/install-script.spec.ts`, covering adoption and curl-style paths with stubbed dependencies. Curl-style installs default to the public `deepseek-ai/deepseek-harness-sdk` source, while replacing the installer with pnpm/npx remains separate work. - -Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting the built layout, the check that catches an unresolved `REPO_ROOT`. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md deleted file mode 100644 index 971bc3b389..0000000000 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: 安装器把已有检出接管进受管布局 - -Status: implemented - -[English](2026-07-31-installer-adopts-existing-checkout.md) | 中文 - -## Problem - -`scripts/install.sh`会产生两种互不兼容的安装布局。`curl … | sh`安装会构建受管布局——`~/.dsh/source/master`处的 master 克隆、位于`dsh-staging/<时间戳>`分支上的 staging worktree,以及 PATH 启动器据以解析的稳定`current`符号链接。而从检出中运行同一脚本时,则依据此前的[检出内跳过克隆决策](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md),把`dsh`直接链接到该检出的`bin/dsh`。 - -这种直接链接无法升级。升级重指的正是`current`,因此缺少它的安装无法通过[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md)升级;检出一旦移动,PATH 符号链接就会失效;而且启动器会解析到贡献者恰好检出的任意分支,这正是升级约定禁止作为启动器目标的情形。升级技能早已把这种布局描述为需要一次性迁移的旧式安装,因此两种布局从安装时起便不相同,只有以后执行迁移才会一致,而迁移也可能永远不执行。 - -## Decision - -检出内模式仍然绝不克隆、绝不修改工作树,但现在它会无条件地把该检出**接管**进受管布局。不存在退出选项:一套布局服务于所有安装。 - -容器拥有 staging worktree 和`current`;仓库是被*发现*的,而非被拥有的。`git rev-parse --git-common-dir`会解析出该检出背后的共享 git 目录——对于 linked worktree,那是真正的克隆而非 worktree 自身——其父目录即是充当升级基础的仓库。随后以该检出的`HEAD`为起点,在`$DSH_SOURCE`下创建 staging worktree,并让`current`指向它。因此,磁盘上任意位置的克隆都会收敛到与`curl`安装相同的布局,且两条路径共用同一套 worktree/exclude/lock/link 流程:二者的唯一差别,只在于仓库是由`git clone`发现的,还是由`git rev-parse`发现的。 - -安装器不会记录该仓库位于何处。仓库位于容器之外时,容器就不是自包含的——每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们——但这一事实本就由 git 自己掌握:worktree 的`.git`文件写明了该路径,而在该克隆中执行`git worktree list`会列出依赖于它的每一个 worktree。 - -接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 - -所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。git 报告的始终是已解析的路径,因此只要检出之上任意一层存在符号链接,拿它与未解析的路径相比较就会不相等——家目录本身是符号链接即已足够,而 macOS 通过`/var` -> `private/var`使每个`mktemp`路径都如此。这种不匹配会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。只要比较的一侧未经解析,同类缺陷就会重现——curl 安装的`REPO_ROOT`以及与之比较的容器路径都曾如此。因此`resolve_dir`在路径不存在时原样回显该路径而非失败,这样尚未创建的容器无需在每个调用点单独兜底,也就没有调用点会因遗漏兜底而与空路径比较;需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 - -在重指`current`之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级约定。 - -## Alternatives considered - -**把`~/.dsh/source/master`做成指向该任意克隆的符号链接。** 已否决。Git 会解析该符号链接并记录*真实*路径:经由它创建的 worktree 会存储`gitdir: …/<克隆>/.git/worktrees/<名称>`,而`git worktree list`报告的是该克隆。因此这个符号链接纯属装饰——没有任何代码读取它——却又暗示容器拥有该仓库。它还会静默失效:移动克隆后,`master`看似仍在却已悬空,而每个 staging worktree 都会以`fatal: not a git repository`失败。最糟的是,它把两个名称别名到同一棵树上,于是"current 绝不能是 master 克隆"这项检查会在字符串比较下通过,实则为假。`~/.dsh/source/master`是位置而非名称,且只有位置具有权威性。 - -**把检出自身提升为`current`的目标。** 已否决:升级约定要求`current`指向 staging 分支上的干净 staging worktree,绝不能指向 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 - -**把就地链接保留在提示或`DSH_ADOPT`开关之后。** 已否决;本次变更的早期修订版本正是如此实现,之后被移除。第二种布局本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动必须处理的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的布局而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的`HEAD`为起点创建的 staging worktree 运行的是同一份代码。`DSH_SOURCE`仍可用于安装另一棵树。 - -**在工作树不干净时发出警告或提示。** 已否决:以`HEAD`为起点的`worktree add`本就无法带上未提交的内容,因此该行为是确定的,提示只会增加一个用户无法做出不同选择的决策点。改为在文档中说明该约定。 - -**把被接管克隆的 staging worktree 放在该克隆旁边**(`~/src/staging-*`),而非放进`~/.dsh/source`。已否决:`current`和 PATH 启动器都是每用户唯一的,因此把 worktree 散落到各个克隆的父目录中,会重新引入 source 容器本就为之而设、意在杜绝的同级克隆蔓延问题。 - -## Consequences - -现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级,而且安装器不再有任何一条分支会产生无法升级的布局。检出内运行仍然绝不改动工作树。 - -代价是:贡献者不能再把 PATH 指向某个检出、并让`dsh`随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管`HEAD`上的 staging worktree。重新运行安装器会再次接管当前的`HEAD`。 - -此外,接管外部克隆的容器不再自包含:删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——诊断依据则是 git 自身的 worktree 记录。 - -## Testing - -`scripts/install.sh` 现有一套位于 `apps/cli/tests/install-script.spec.ts` 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。curl 风格安装默认使用公开的 `deepseek-ai/deepseek-harness-sdk` 源,而以 pnpm/npx 替换安装器仍是另一项工作。 - -验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——该检查能捕获未解析的`REPO_ROOT`。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml similarity index 53% rename from .agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml index eb11cc1b92..13bf0cac84 100644 --- a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml @@ -1,6 +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/process/2026-07-23-personal-staging-maintenance-skills.md -2026-07-23-personal-staging-maintenance-skills.md: a7ccc5b1e0f13e880c58a93d2e4c2cd4f06e2a93 -2026-07-23-personal-staging-maintenance-skills.zh.md: 8593291f510352429cbb29679e7d033589312e41 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md +2026-08-10-source-run-without-managed-installer.md: 959b6fc6cad5113bb3ba0d17ee27985db5ece82e +2026-08-10-source-run-without-managed-installer.zh.md: 9df969f3754b967fd1b3498191b7c9c2b0a41656 diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md new file mode 100644 index 0000000000..959b6fc6ca --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md @@ -0,0 +1,31 @@ +# Agent Note: Source run without a managed installer + +Status: implemented + +English | [中文](2026-08-10-source-run-without-managed-installer.zh.md) + +## Problem + +A repository-owned source installer can provide a stable launcher, isolated staging worktrees, atomic upgrades, rollback storage, and shared maintenance workflows for personal customizations. It also makes the repository responsible for a second lifecycle beside the package manager: host dependency installation, credential prompting, checkout adoption, symlink ownership, staging branch coordination, upgrade recovery, and continued compatibility between the installer and bundled maintenance skills. + +That lifecycle is not required to run or develop DeepSeek Harness from a source checkout. Maintaining it expands the supported filesystem and Git state space without improving the repository-native execution path. + +## Decision + +The repository supports source execution through its `pnpm run` scripts. The root README directs users to clone the repository, run `pnpm install`, configure credentials through the environment or root `.env`, and launch the required interface with `pnpm run demo:web`, `pnpm run demo:headless`, `pnpm run demo:acp`, or `pnpm run dsh`. + +The repository does not distribute a source installer, an installer test suite, or skills that assume a managed `current` symlink and timestamped staging worktrees. Users own source checkout placement, Git updates, and any launcher they create outside the repository. + +## Alternatives considered + +**Keep the installer but document `pnpm run` as another path.** This retains the managed launcher and rollback capability but keeps both lifecycle contracts active, including the installer tests and staging-aware skills. + +**Keep generic customization and upstream-publication skills.** Their safety rules can apply beyond the staging layout, but the shipped workflows form one coupled maintenance system: customization discovers the installed staging checkout, upgrade performs the cutover, and upstream publication is selected from those personal changes. General Git contribution guidance already belongs to repository instructions and does not require product-bundled skills. + +**Replace the installer with a smaller launcher-link script.** This reduces setup behavior but still makes the repository responsible for host PATH mutation and launcher ownership. Source scripts provide the entry points without that state. + +## Consequences + +Source users invoke repository scripts rather than an installed `dsh` command. The repository provides no atomic upgrade cutover or preserved staging rollback checkout, and it does not automate the integration or upstream publication of personal source modifications. A future distribution mechanism must justify its ownership of installation and upgrade state, define recovery behavior, and add tests and user documentation without making the source-run path depend on it. Any future publication workflow must isolate one approved feature and obtain explicit approval before its first push and draft PR. + +Verification covers repository-wide references to the removed entry points, documentation links, generated third-party-notice freshness, and a source CLI smoke through `pnpm run`. diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md new file mode 100644 index 0000000000..9df969f375 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 无需托管安装器的源码运行 + +Status: implemented + +[English](2026-08-10-source-run-without-managed-installer.md) | 中文 + +## 问题 + +仓库自带的源码安装器可以提供稳定的启动器、相互隔离的 staging worktree、原子升级、回滚存储,以及用于个人定制的共享维护工作流。与此同时,仓库还必须在包管理器之外负责第二套生命周期:安装宿主依赖、提示输入凭证、接管检出、管理符号链接归属、协调 staging 分支、处理升级恢复,以及持续保持安装器与随附维护 skill(技能)的兼容性。 + +从源码检出运行或开发 DeepSeek Harness 并不需要这套生命周期。维护它会扩大需要支持的文件系统和 Git 状态空间,却无法改进仓库原生的执行路径。 + +## 决策 + +仓库通过 `pnpm run` 脚本支持从源码运行。根 README 指示用户克隆仓库、运行 `pnpm install`、通过环境变量或根目录 `.env` 配置凭证,并使用 `pnpm run demo:web`、`pnpm run demo:headless`、`pnpm run demo:acp` 或 `pnpm run dsh` 启动所需接口。 + +仓库不分发源码安装器、安装器测试套件,也不分发依赖受管理的 `current` 符号链接和带时间戳 staging worktree 的 skill。源码检出的存放位置、Git 更新,以及用户在仓库外创建的任何启动器均由用户负责。 + +## 考虑过的备选方案 + +**保留安装器,但将 `pnpm run` 记作另一条路径。**这样可以保留受管理的启动器和回滚能力,但两套生命周期约定仍会同时生效,其中包括安装器测试和依赖 staging 布局的 skill。 + +**保留通用的定制与上游发布 skill。**其中的安全规则也能用于 staging 布局之外,但现有工作流共同构成了一套耦合的维护系统:定制工作流查找已安装的 staging 检出,升级工作流执行切换,上游发布工作流则从这些个人修改中选择发布内容。通用 Git 贡献指南已经属于仓库指令,无需以产品随附 skill 的形式提供。 + +**用更小的启动器链接脚本替换安装器。**这样可以简化设置过程,但仓库仍需负责修改宿主 PATH 和管理启动器归属。源码脚本无需引入这类状态即可提供入口点。 + +## 影响 + +源码用户通过仓库脚本运行程序,而非使用已安装的 `dsh` 命令。仓库不提供原子升级切换,也不保留 staging 回滚检出;仓库同样不会自动集成个人源码修改或将其发布到上游。未来的分发机制必须说明为何应由其管理安装和升级状态,定义恢复行为,并补充测试与用户文档,同时不得让源码运行路径依赖该机制。未来任何发布工作流都必须隔离出一项获批功能,并在首次推送和创建草稿 PR(Pull Request)前取得明确批准。 + +验证范围包括仓库内对已移除入口点的所有引用、文档链接、生成的第三方声明文件的新鲜度,以及通过 `pnpm run` 启动源码 CLI(命令行界面)的冒烟测试。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 43185dc239..dd2d183633 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: 9b8944027572072e08a39bd9e482996f8128224c -README.zh.md: a1b2b6a36c4baac8a49b88a42c9f17c986287ff1 +README.md: f929f9746864857fdaf2313f0d5b63d3647adab5 +README.zh.md: 8886016f8afdcd710715a9f4178125e3af9fc788 diff --git a/README.md b/README.md index 9b89440275..f929f97468 100644 --- a/README.md +++ b/README.md @@ -12,41 +12,38 @@ DeepSeek Harness is under internal testing. Features and interfaces may change. The 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. -## Install +## Run from source -Clone the repository, then run the installer: +Install `git`, Node `^22.19 || >=24`, and Corepack-enabled `pnpm`, then prepare a source checkout: ```sh -git clone +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness -scripts/install.sh +pnpm install ``` -The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI. - -The default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options. +Set `DEEPSEEK_API_KEY` in the environment or the repository's root `.env`. Run DeepSeek Harness from this checkout through the `pnpm run` commands below. ## Use DeepSeek Harness ### Web UI -For the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run: +Start the recommended local interface from the repository root: ```sh -(cd ~/.dsh/source/current && pnpm run build) -dsh web +pnpm run demo:web ``` -The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default. +The command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default. ### Profiles -`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`: +The source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`: ```sh -dsh --profile web # the browser UI (same as: dsh web) -dsh plugin --profile tui add # install a plugin into a custom profile -dsh --profile tui # boot it +pnpm run dsh --profile web # the browser UI +pnpm run dsh plugin --profile tui add # install a plugin into a custom profile +pnpm run dsh --profile tui # boot it ``` The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands. @@ -56,7 +53,7 @@ The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -dsh --profile headless "summarize this workspace" +pnpm run demo:headless "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index a1b2b6a36c..8886016f8a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -12,41 +12,38 @@ DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化 为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。 -## 安装 +## 从源码运行 -克隆仓库,然后运行安装器: +请安装 `git` 和 Node `^22.19 || >=24`,并通过 Corepack 启用 `pnpm`,然后准备源码检出: ```sh -git clone +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness -scripts/install.sh +pnpm install ``` -安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。 - -默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。 +请通过环境变量或仓库根目录的 `.env` 设置 `DEEPSEEK_API_KEY`。使用下方 `pnpm run` 命令从该检出运行 DeepSeek Harness。 ## 使用 DeepSeek Harness ### Web UI -推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行: +请从仓库根目录启动推荐的本地界面: ```sh -(cd ~/.dsh/source/current && pnpm run build) -dsh web +pnpm run demo:web ``` -上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 +该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 ### Profile -`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层: +源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层: ```sh -dsh --profile web # the browser UI (same as: dsh web) -dsh plugin --profile tui add # install a plugin into a custom profile -dsh --profile tui # boot it +pnpm run dsh --profile web # the browser UI +pnpm run dsh plugin --profile tui add # install a plugin into a custom profile +pnpm run dsh --profile tui # boot it ``` profile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。 @@ -56,7 +53,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -dsh --profile headless "summarize this workspace" +pnpm run demo:headless "summarize this workspace" ``` ### 自动化与 SDK diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 586cd240a0..0ecae4b668 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -27,7 +27,7 @@ The Cordis framework and its foundation libraries are source-vendored into this ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. `scripts/install.sh` installs this repository itself, so the tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. +External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. | Package | License | | --- | --- | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 04026262c8..db6bb56a41 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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/README.md -README.md: 4fae5338a89ce12c2620e123530acf883ae9efff -README.zh.md: a2d086b8ff12fb07f2446fc4162de09739bcdeab +README.md: 082c0b98c08a26fd0468b770c3a9b7332bc5bcfd +README.zh.md: a3a983939ed6fef02c4929dde957762d1fae4c64 diff --git a/apps/cli/README.md b/apps/cli/README.md index 4fae5338a8..082c0b98c0 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -31,8 +31,8 @@ dsh --help # the launcher's own help A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. -The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher. +The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution. ## Development -Production runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract. +Production runs require built package and frontend artifacts. From the repository root, use `pnpm run dsh ` to run the TypeScript entry and forward arguments; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index a2d086b8ff..a3a983939e 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -31,8 +31,8 @@ dsh --help # the launcher's own help profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 -[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。 +[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码执行。 ## 开发 -生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。 +生产运行需要已构建的包与前端产物。请从仓库根目录使用 `pnpm run dsh ` 运行 TypeScript 入口并转发参数;模块解析约定由[源码执行参考](reference/README.md#source-execution)负责。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 8f6db85427..b98a541986 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: fd0647347312051a1814a5e3464b34032ae70dfc -README.zh.md: afe4b9ba5651e962288ffebbe7c095ad20bcc617 +README.md: e9db27515cf6fa6c910bc684f0250da0a189d335 +README.zh.md: a30fab8da26056956d1f8cad7c73e7cb39287b85 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index fd06473473..e9db27515c 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -79,12 +79,6 @@ Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` sel Install external plugin bundles through `dsh plugin --profile add `. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. -## Source launcher +## Source execution -Link the source-running launcher onto PATH: - -```sh -ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh -``` - -It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`. +From the repository root, use `pnpm run dsh `. The script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` and forwards every argument. The built form is `apps/cli/lib/bin.js` after `pnpm run build`. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index afe4b9ba56..a30fab8da2 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -79,12 +79,6 @@ dsh web --help 通过 `dsh plugin --profile add ` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 -## 源码启动器 +## 源码执行 -把源码运行启动器链接到 PATH: - -```sh -ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh -``` - -它通过 real path 解析 checkout,并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`。`TSX_TSCONFIG_PATH` 固定到 checkout 根目录,因此 workspace 包解析不依赖调用目录。`pnpm run dsh` 使用同一入口并转发参数。运行 `pnpm run build` 后,构建形式为 `apps/cli/lib/bin.js`。 +请从仓库根目录使用 `pnpm run dsh `。该脚本通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。运行 `pnpm run build` 后,构建形式为 `apps/cli/lib/bin.js`。 diff --git a/apps/cli/tests/install-script.spec.ts b/apps/cli/tests/install-script.spec.ts deleted file mode 100644 index 871f72d056..0000000000 --- a/apps/cli/tests/install-script.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { execa } from 'execa' -import { afterEach, describe, expect, it } from 'vitest' - -const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url)) -const fixtures: string[] = [] - -const PTY_DRIVER = String.raw` -import errno, json, os, pty, select, signal, sys, time -script, cwd, env_json, actions_json = sys.argv[1:] -env = os.environ.copy() -env.update(json.loads(env_json)) -actions = json.loads(actions_json) -pid, fd = pty.fork() -if pid == 0: - os.chdir(cwd) - os.execvpe("sh", ["sh", script], env) - -output = bytearray() -action_index = 0 -deadline = time.monotonic() + 15 -status = None -while time.monotonic() < deadline: - ready, _, _ = select.select([fd], [], [], 0.05) - if ready: - try: - chunk = os.read(fd, 65536) - except OSError as error: - if error.errno != errno.EIO: - raise - chunk = b"" - output.extend(chunk) - while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output: - os.write(fd, actions[action_index]["send"].encode()) - action_index += 1 - waited, candidate = os.waitpid(pid, os.WNOHANG) - if waited == pid: - status = candidate - break - -if status is None: - os.kill(pid, signal.SIGKILL) - _, status = os.waitpid(pid, 0) -sys.stdout.buffer.write(output) -if action_index != len(actions): - sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n") - sys.exit(124) -sys.exit(os.waitstatus_to_exitcode(status)) -` - -interface Action { - readonly waitFor: string - readonly send: string -} - -interface Fixture { - readonly binDirectory: string - readonly launchLog: string - readonly pnpmLog: string - readonly root: string - readonly script: string -} - -afterEach(async () => { - await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) })) -}) - -function executable(path: string, content: string): void { - writeFileSync(path, content) - chmodSync(path, 0o755) -} - -async function createFixture(): Promise { - const root = await mkdtemp(join(tmpdir(), 'dsh-install-')) - fixtures.push(root) - const checkoutDirectory = join(root, 'checkout') - const scriptsDirectory = join(checkoutDirectory, 'scripts') - const sourceBinDirectory = join(checkoutDirectory, 'bin') - const fakeBinDirectory = join(root, 'fake-bin') - const binDirectory = join(root, 'path-bin') - for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) { - mkdirSync(directory, { recursive: true }) - } - const script = join(scriptsDirectory, 'install.sh') - copyFileSync(installer, script) - const launchLog = join(root, 'launch.log') - const pnpmLog = join(root, 'pnpm.log') - executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n') - executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh -if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi -printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG" -`) - await execa('git', ['init', '-q'], { cwd: checkoutDirectory }) - await execa('git', ['add', 'bin/dsh', 'scripts/install.sh'], { cwd: checkoutDirectory }) - await execa('git', [ - '-c', 'user.name=dsh-test', - '-c', 'user.email=dsh-test@example.invalid', - 'commit', '-qm', 'fixture', - ], { cwd: checkoutDirectory }) - writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n') - return { binDirectory, launchLog, pnpmLog, root, script } -} - -async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise { - const result = await execa('python3', [ - '-c', - PTY_DRIVER, - fixture.script, - fixture.root, - JSON.stringify({ - DSH_BIN_DIR: fixture.binDirectory, - DSH_HOME: join(fixture.root, 'home/.dsh'), - DSH_TEST_LAUNCH_LOG: fixture.launchLog, - DSH_TEST_PNPM_LOG: fixture.pnpmLog, - HOME: join(fixture.root, 'home'), - PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`, - }), - JSON.stringify(actions), - ], { reject: false, stripFinalNewline: false, timeout: 20_000 }) - expect(result.exitCode, result.stderr).toBe(0) - return result.stdout -} - -describe.runIf(process.platform !== 'win32')('one-line installer launch', { timeout: 25_000 }, () => { - it('builds and launches the Web UI', async () => { - const fixture = await createFixture() - - const output = await runInstaller(fixture, [ - { waitFor: 'Replace it?', send: '\n' }, - ]) - - expect(output).toContain('launching Web UI') - expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n') - expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n') - }) -}) diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index bd2268231b..29f17ac7b9 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -3,9 +3,9 @@ import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** - * Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts` + * Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts` * with the exact production launch vector (`node --import tsx/esm`, the same - * executable and arguments as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the + * shape as the root `dsh` script) and assert the * required-config diagnostic. The Node compatibility matrix runs this * WHOLE file, so a Node release changing module hooks or TypeScript handling * breaks this gate instead of every developer's `pnpm dsh`; the built-bin diff --git a/bin/dsh b/bin/dsh deleted file mode 100755 index c578d78e74..0000000000 --- a/bin/dsh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh -# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through the tsx ESM -# hook, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the -# current working tree without a build step. -set -eu - -# Resolve symlink chains without readlink -f (not on every macOS). -script=$0 -while [ -L "$script" ]; do - target=$(readlink "$script") - case $target in - /*) script=$target ;; - *) script=$(dirname "$script")/$target ;; - esac -done -root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd) - -# The ESM-only tsx hook transforms TypeScript and projects this checkout's -# tsconfig paths into Node resolution (the CJS hook stays off: the graph is -# ESM-only and the CJS resolver costs ~0.4s of startup). Absolute paths keep -# both the hook and the tsconfig anchored to this checkout when the launcher -# runs from any cwd, where bare `tsx/esm` would not resolve. -NODE_USE_ENV_PROXY=1 \ - TSX_TSCONFIG_PATH="$root/tsconfig.json" \ - exec node --import "$root/node_modules/tsx/dist/esm/index.mjs" \ - "$root/apps/cli/src/bin.ts" "$@" diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index ba4cbd5f55..9f5e41be77 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: 06f98546c8858af08efcb07c007d8e1b95b90c19 -api-gateway.zh.md: 82bf8923c45151d0c00e71848be89806427d1ce3 +api-gateway.md: 7c666dbc431b076262a9a93f1baefd88a29237e8 +api-gateway.zh.md: eee783def7e981dc888e1192c6e1a1f9ccd424c4 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 06f98546c8..7c666dbc43 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -147,7 +147,7 @@ pnpm run build Web development normally starts the source Host after one complete build and runs the Client plugin watcher in another terminal: ```sh -pnpm run dsh -- web --dev +pnpm run dsh web --dev pnpm run dev:web ``` diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 82bf8923c4..eee783def7 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -147,7 +147,7 @@ pnpm run build Web 开发通常在完成一次构建后启动源码 Host,并在另一个终端运行 Client plugin watcher: ```sh -pnpm run dsh -- web --dev +pnpm run dsh web --dev pnpm run dev:web ``` diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index d1f9a72df5..cd80f4b8eb 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/app-boot/README.md -README.md: 1d56b2b6d22c08574f8e361955bee1dbe2aca601 -README.zh.md: 5429a1322d0311f03c7c43946753a290e28cd936 +README.md: 9880a4789b85f516ed16ead293ebb5c10da0d57f +README.zh.md: b070696e5195feb663ad44605f55553eac672656 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 1d56b2b6d2..9880a4789b 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -29,7 +29,7 @@ The Loader mounts entries concurrently, so a surface can already own the termina `cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `dsh` source launcher additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `pnpm run dsh` source path additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 5429a1322d..b070696e51 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -29,7 +29,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 `cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`pnpm run dsh` 源码路径还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index faf8c756dd..fb6838de54 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -27,8 +27,8 @@ const ALL_KINDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'p * root manifest), test infrastructure, the documentation site, the runnable * demo leaves, and the native launcher's build workspace. A runtime * declaration by anything outside these areas is a disclosure-relevant - * runtime dependency, because `scripts/install.sh` installs the repository - * itself and any plugin package can be mounted from a user's `cordis.yml`. + * runtime dependency because any plugin package can be mounted from a user's + * `cordis.yml`. */ const DEV_ONLY_AREAS = [ 'package.json', @@ -369,7 +369,7 @@ function collectNpmDeps(): ExternalDep[] { */ export function tierExternalDeps(manifests: Map, names: Set): Map { const tiers = new Map() - // `tsx` is runtime by fiat: `bin/dsh` execs the CLI through its ESM hook. + // `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook. tiers.set('tsx', true) for (const [path, manifest] of manifests) { const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area)) @@ -707,7 +707,7 @@ ${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.u ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default. +External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default. ${renderNpmTable(runtimeDeps)} diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index f290782892..0000000000 --- a/scripts/install.sh +++ /dev/null @@ -1,425 +0,0 @@ -#!/bin/sh -# dsh one-line installer. -# -# curl -fsSL https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/scripts/install.sh | sh -# -# It clones the harness under ~/.dsh/source (the master clone at -# ~/.dsh/source/master), adds a per-install staging worktree at -# ~/.dsh/source/staging- on branch dsh-staging/, checks -# host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs -# `pnpm install`, points the stable `~/.dsh/source/current` symlink -# at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`, -# records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot, -# builds the repository artifacts, and launches the Web UI. Keeping every -# checkout under ~/.dsh/source keeps successive -# upgrades in one place instead of scattered sibling clones, and lets staging -# worktrees share the master clone's object store. The PATH symlink resolves through -# `current`, so an upgrade repoints one stable symlink instead of relinking PATH: -# the `dsh` on PATH never moves and can never dangle. -# -# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather -# than `curl ... | sh`) it never clones and never touches that working tree; -# DSH_REF is ignored. Instead it *adopts* the checkout: `git rev-parse -# --git-common-dir` resolves the repository behind it (for a linked worktree that -# is the real clone, not the worktree), and a fresh staging worktree branched -# from the checkout's HEAD lands in the source container beside `current`. The -# container owns staging worktrees and `current`; the clone is discovered, not -# owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one -# layout and stay upgradable. Adoption carries committed work only: the staging -# worktree branches from HEAD, so uncommitted changes stay in the checkout. -# Setting DSH_SOURCE to a different directory opts back into the normal -# clone/worktree path. -# -# Adopting an arbitrary clone leaves the container not self-contained: its -# staging worktrees hold an absolute gitdir pointer into that clone, so deleting -# it breaks them. `git worktree list` in that clone is the record of which -# worktrees depend on it. -# -# When run through `curl | sh` the script text arrives on stdin, so every -# prompt and the final launch read the controlling terminal (/dev/tty) directly; -# with no terminal the script prints the manual next steps instead. -# -# Overridable via environment: -# DSH_REF branch or tag to clone/checkout (default: master) -# DSH_REPO clone URL (default: the GitHub repo) -# DSH_SOURCE source container directory (default: ~/.dsh/source) -# DSH_MASTER master clone directory (default: $DSH_SOURCE/master) -# DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) -# DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) -# DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh) -set -eu - -DSH_REF=${DSH_REF:-master} -DSH_REPO=${DSH_REPO:-https://github.com/deepseek-ai/deepseek-harness-sdk.git} -# DSH_SOURCE is the staging-worktree container and the default home of `current`. -# DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE, -# while adoption discovers an existing clone anywhere on disk. Remember whether -# DSH_SOURCE was explicit so a different path selects clone mode. -if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi -DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source} -DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master} -# The stable symlink the PATH launcher resolves through: PATH/dsh -> -# current/bin/dsh -> /bin/dsh. Installs and upgrades repoint `current`; -# the PATH target remains current/bin/dsh. -DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current} -DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin} -# One UTC basic timestamp names this install's staging branch and worktree. -DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ) -DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP -DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP - -# --- path helpers --------------------------------------------------------------- -# Every path comparison below runs on physical paths. Git always reports resolved -# paths, so comparing one against an unresolved path disagrees whenever a symlink -# sits anywhere above the checkout — a symlinked home directory is enough, and -# macOS reaches every mktemp path that way through /var -> private/var. The -# mismatch silently misclassifies an existing managed install as a foreign clone -# and builds a second container beside the real one. -# `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+. -# -# A not-yet-created directory (the container on a fresh install) has no physical -# path. Falling back here rather than at each call site keeps every caller a -# plain assignment, so no site can compare against an empty path by forgetting -# its own fallback. -resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; } - -# --- in-repo detection --------------------------------------------------------- -# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell -# name and no file path resolves; running a checked-out copy (`sh -# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose -# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present), -# this is in-repo mode: never clone, never touch that working tree. An explicit -# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path. -IN_REPO=0 -DSH_CHECKOUT='' -if [ -f "$0" ]; then - _self_dir=$(resolve_dir "$(dirname -- "$0")") - if [ -n "$_self_dir" ]; then - # Physical without its own resolve_dir: dirname is textual, so trimming a - # resolved path leaves one. The comparison below depends on that. - _repo_root=$(dirname -- "$_self_dir") - if [ "$(basename -- "$_self_dir")" = scripts ] \ - && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then - # Compare the explicit DSH_SOURCE physically: an unresolved but equivalent - # path must still count as "the caller meant this checkout". - _src_resolved=$(resolve_dir "$DSH_SOURCE") - if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ]; then - IN_REPO=1 - DSH_CHECKOUT=$_repo_root - fi - fi - fi -fi - -# --- terminal-aware prompting -------------------------------------------------- -# stdin is the piped script, so read the controlling terminal for input. -if { true /dev/null; then - HAS_TTY=1 - # Restore terminal echo on exit or interrupt: ask_secret disables echo between - # its stty toggles, and dash (a common `sh`) does not run an EXIT trap when the - # shell is killed by a signal, so the fatal signals need their own handler. A - # successful run ends in exec, which replaces this process and drops the traps. - trap 'stty echo /dev/null || true' EXIT - trap 'stty echo /dev/null || true; exit 130' INT TERM HUP -else - HAS_TTY=0 -fi - -# Colour only when writing to a terminal. -if [ -t 1 ]; then - B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m') - GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RST=$(printf '\033[0m') -else - B=''; DIM=''; RED=''; GRN=''; YEL=''; RST='' -fi - -info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$1"; } -step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$RST" "$B" "$1" "$RST"; } -warn() { printf '%s warn%s %s\n' "$YEL" "$RST" "$1" >&2; } -die() { printf '%serror%s %s\n' "$RED" "$RST" "$1" >&2; exit 1; } - -# ask PROMPT [DEFAULT] -> answer on stdout (plain-text line). -ask() { - [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell" - printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty - IFS= read -r _ans answer on stdout, with terminal echo suppressed. -ask_secret() { - [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell" - printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty - stty -echo /dev/null || true - IFS= read -r _sec /dev/null || true - printf '\n' >/dev/tty - printf '%s' "$_sec" -} - -# confirm PROMPT [Y] -> exit 0 on yes. Default is no unless second arg is "Y". -confirm() { - _def=${2:-N} - if [ "$HAS_TTY" != 1 ]; then - [ "$_def" = Y ] # non-interactive: take the default - return - fi - if [ "$_def" = Y ]; then _hint='[Y/n]'; else _hint='[y/N]'; fi - printf '%s%s%s %s ' "$B" "$1" "$RST" "$_hint" >/dev/tty - IFS= read -r _r /dev/null 2>&1 || die "git is required but not found. Install git, then re-run." -info "git ... ok" - -# Node ^22.19.0 || >=24.0.0 (see the root package.json "engines" field). -node_ok() { - command -v node >/dev/null 2>&1 || return 1 - _v=$(node -v 2>/dev/null) || return 1 - _v=${_v#v} - _major=${_v%%.*} - _rest=${_v#*.} - _minor=${_rest%%.*} - case "$_major" in ''|*[!0-9]*) return 1 ;; esac - case "$_minor" in ''|*[!0-9]*) _minor=0 ;; esac - [ "$_major" -ge 24 ] && return 0 - [ "$_major" -eq 22 ] && [ "$_minor" -ge 19 ] && return 0 - return 1 -} -if node_ok; then - info "node $(node -v) ... ok" -else - if command -v node >/dev/null 2>&1; then - die "Node $(node -v) is unsupported. dsh needs ^22.19.0 || >=24.0.0 — upgrade Node, then re-run." - fi - die "Node is required but not found. Install Node ^22.19.0 || >=24, then re-run." -fi - -# pnpm is the only dependency we offer to install for you. -if command -v pnpm >/dev/null 2>&1; then - info "pnpm $(pnpm --version) ... ok" -else - warn "pnpm is not installed." - if confirm "Install pnpm now?" Y; then - if command -v corepack >/dev/null 2>&1 && corepack enable pnpm >/dev/null 2>&1; then - info "enabled pnpm via corepack" - elif command -v npm >/dev/null 2>&1 && npm install -g pnpm >/dev/null 2>&1; then - info "installed pnpm via npm" - else - die "could not install pnpm automatically. Install it (https://pnpm.io/installation), then re-run." - fi - command -v pnpm >/dev/null 2>&1 || die "pnpm still not on PATH after install. Open a new shell, then re-run." - else - die "pnpm is required. Install it (https://pnpm.io/installation), then re-run." - fi -fi - -# --- 2. resolve the repository and lay out the staging worktree --------------- -# The source container owns staging worktrees and `current`; the repository is -# *discovered*, not owned. A curl install discovers it by cloning to $DSH_MASTER; -# in-repo adoption discovers it from the checkout. Both then run one shared -# worktree/exclude/lock path, so an arbitrary clone and a managed install -# converge on the same layout. -# -# REPO_COMMON is the shared git directory every worktree of the repository -# points at; REPO_ROOT is the working tree that owns it (the master clone). -REPO_COMMON='' -REPO_ROOT='' - -if [ "$IN_REPO" = 1 ]; then - step "Using existing checkout at $DSH_CHECKOUT" - info "running from inside the repo — never cloning, and DSH_REF is ignored" - - # Resolve the repository behind the checkout. --git-common-dir returns the - # SHARED git dir, so a linked worktree resolves to the real clone rather than - # itself; it is relative for a plain clone, so anchor it before resolving. - # Require the resolved git dir to exist: resolve_dir echoes its argument back - # for a missing path, so test the directory rather than the returned string. - if _common=$(git -C "$DSH_CHECKOUT" rev-parse --git-common-dir 2>/dev/null) && [ -n "$_common" ]; then - case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac - [ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common") - fi - [ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it." - REPO_ROOT=$(dirname -- "$REPO_COMMON") - - # Reuse the container when the repository already lives inside it (the normal - # managed install re-running its own script); otherwise treat that clone as - # its own master and keep worktrees in the default container. - _src_resolved=$(resolve_dir "$DSH_SOURCE") - case "$REPO_ROOT/" in - "$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;; - *) info "adopting clone $REPO_ROOT as its own master" ;; - esac - DSH_MASTER=$REPO_ROOT -else - step "Fetching source into $DSH_MASTER" - if [ -d "$DSH_MASTER/.git" ]; then - info "existing master clone found — updating" - git -C "$DSH_MASTER" fetch origin "$DSH_REF" - # Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not - # origin/) so this resolves for a tag as well as a branch, and -B makes - # the re-run idempotent whether or not DSH_REF changed since the last install. - git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD - else - mkdir -p "$DSH_SOURCE" - git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER" - fi - # Physical on both branches: REPO_ROOT is compared against resolved paths - # below, and REPO_COMMON stays symmetric with it so neither can be read as - # carrying a different kind of path. - REPO_COMMON=$(resolve_dir "$DSH_MASTER/.git") - REPO_ROOT=$(resolve_dir "$DSH_MASTER") -fi - -step "Adding staging worktree at $DSH_STAGING" -[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." -mkdir -p "$DSH_SOURCE" -# The staging worktree owns the branch dsh runs from; the repository stays as -# the fetch/upgrade base and is never a launcher target. A clone install -# branches from the ref it just fetched; adoption branches from the checkout's -# HEAD so the contributor's committed work is what runs. -if [ "$IN_REPO" = 1 ]; then - git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD -else - git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ - || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD -fi -# Exclude the per-worktree merge lock in the shared git dir's info/exclude, -# which every linked worktree inherits. -_exclude="$REPO_COMMON/info/exclude" -if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then - printf '.agents/merge.lock\n' >>"$_exclude" -fi -mkdir -p "$DSH_STAGING/.agents" -: >"$DSH_STAGING/.agents/merge.lock" - -# --- 3. install dependencies (no build; the launcher runs from source) -------- -step "Installing dependencies with pnpm (this can take a while)" -( cd "$DSH_STAGING" && pnpm install ) - -[ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" - -# --- 4. put `dsh` on PATH ------------------------------------------------------ -# Every install goes through a stable `current` symlink so an upgrade repoints -# one symlink (current -> new worktree) and the PATH launcher never moves: -# PATH/dsh -> current/bin/dsh -> /bin/dsh. -step "Linking dsh into $DSH_BIN_DIR" -mkdir -p "$DSH_BIN_DIR" -# The launcher must resolve to a staging worktree, never to the repository -# itself: an upgrade repoints `current`, so aliasing it onto the master clone -# would make every upgrade rewrite the fetch/upgrade base. Compare physical -# paths — a symlinked or unresolved path would slip past a string compare. -_staging_resolved=$(resolve_dir "$DSH_STAGING") -[ "$_staging_resolved" = "$REPO_ROOT" ] \ - && die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree." -# Point `current` at this staging worktree with `ln -sfn`: -f replaces an -# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing -# an existing symlink-to-directory and dropping the new link *inside* the old -# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir -# symlink the same way. The swap is one unlink+symlink pair on a local fs; the -# installer holds no other process racing this path. -ln -sfn "$DSH_STAGING" "$DSH_CURRENT" -info "pointed $DSH_CURRENT -> $DSH_STAGING" -DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh -ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" -info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" - -case ":$PATH:" in - *":$DSH_BIN_DIR:"*) ON_PATH=1 ;; - *) ON_PATH=0 ;; -esac -if [ "$ON_PATH" = 0 ]; then - warn "$DSH_BIN_DIR is not on your PATH." - _line="export PATH=\"$DSH_BIN_DIR:\$PATH\"" - _rc='' - _sh=${SHELL:-} # SHELL may be unset; word-removal on an unset var trips set -u under dash. - case "${_sh##*/}" in - zsh) _rc="$HOME/.zshrc" ;; - bash) _rc="$HOME/.bashrc" ;; - esac - if [ -n "$_rc" ] && [ -f "$_rc" ] && grep -qF "$_line" "$_rc" 2>/dev/null; then - info "$_rc already exports $DSH_BIN_DIR — open a new shell to pick it up" - elif [ -n "$_rc" ] && confirm "Add it to $_rc?" Y; then - printf '\n# Added by the dsh installer\n%s\n' "$_line" >>"$_rc" - info "updated $_rc — run 'source $_rc' or open a new shell to pick it up" - else - warn "add this line to your shell profile yourself:" - printf ' %s\n' "$_line" - fi -fi - -# --- 5. credentials ------------------------------------------------------------ -# Mirror app-boot's resolveDshHome precedence ($DSH_HOME, else ~/.dsh) so creds land where dsh reads them. -if [ -n "${DSH_HOME:-}" ]; then - CONF="$DSH_HOME" -else - CONF="$HOME/.dsh" -fi -ENV_FILE="$CONF/.env" - -step "Configuring credentials" -if [ -f "$ENV_FILE" ] && grep -q '^DEEPSEEK_API_KEY=' "$ENV_FILE" 2>/dev/null; then - info "DEEPSEEK_API_KEY already set in $ENV_FILE" - if ! confirm "Replace it?" N; then - SKIP_CREDS=1 - fi -fi -if [ "${SKIP_CREDS:-0}" != 1 ]; then - if [ "$HAS_TTY" = 1 ]; then - API_KEY=$(ask_secret "DeepSeek API key (input hidden):") - if [ -z "$API_KEY" ]; then - warn "no key entered — skipping. Set DEEPSEEK_API_KEY in $ENV_FILE before using dsh." - else - BASE_URL=$(ask "DeepSeek base URL (optional, Enter to skip):") - mkdir -p "$CONF" - # The installer owns exactly the two DEEPSEEK_* lines; any other lines the - # user keeps in this .env are preserved. The rewrite happens in a subshell - # so umask 077 (which closes the create-time permission race) does not leak - # into the exec'd dsh, and lands atomically via a same-dir temp + mv. - _tmp="$ENV_FILE.dsh.$$" - ( - umask 077 - if [ -f "$ENV_FILE" ]; then - grep -v -e '^DEEPSEEK_API_KEY=' -e '^DEEPSEEK_BASE_URL=' "$ENV_FILE" >"$_tmp" || true - else - : >"$_tmp" - fi - printf 'DEEPSEEK_API_KEY=%s\n' "$API_KEY" >>"$_tmp" - if [ -n "$BASE_URL" ]; then printf 'DEEPSEEK_BASE_URL=%s\n' "$BASE_URL" >>"$_tmp"; fi - ) - mv "$_tmp" "$ENV_FILE" - chmod 600 "$ENV_FILE" 2>/dev/null || true - info "wrote $ENV_FILE" - fi - else - warn "no terminal for credential input — set DEEPSEEK_API_KEY in $ENV_FILE before using dsh." - fi -fi - -# --- 6. build and launch the Web interface ------------------------------------- -step "Done" -if [ "$HAS_TTY" = 1 ]; then - step "Building DeepSeek Harness for Web UI" - ( cd "$DSH_STAGING" && pnpm run build ) - info "launching Web UI — run 'dsh web' anytime to start again" - exec "$DSH_BIN_DIR/dsh" web \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --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\ndsh --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\nInstall `git`, Node `^22.19 || >=24`, and Corepack-enabled `pnpm`, then prepare a source checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness-sdk.git\ncd deepseek-harness\npnpm install\n```\n\nSet `DEEPSEEK_API_KEY` in the environment or the repository's root `.env`. Run DeepSeek Harness from this checkout through the `pnpm run` commands below.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm run demo: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 run dsh --profile web # the browser UI\npnpm run dsh plugin --profile tui add # install a plugin into a custom profile\npnpm run 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 run demo: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" }, { "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克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh --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请安装 `git` 和 Node `^22.19 || >=24`,并通过 Corepack 启用 `pnpm`,然后准备源码检出:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness-sdk.git\ncd deepseek-harness\npnpm install\n```\n\n请通过环境变量或仓库根目录的 `.env` 设置 `DEEPSEEK_API_KEY`。使用下方 `pnpm run` 命令从该检出运行 DeepSeek Harness。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm run demo: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 run dsh --profile web # the browser UI\npnpm run dsh plugin --profile tui add # install a plugin into a custom profile\npnpm run dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm run demo: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" }, { "role": "user", diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md deleted file mode 100644 index 5d67af1e51..0000000000 --- a/skills/dsh-customize/SKILL.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: dsh-customize -description: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly. ---- - -# DSH Customize - -Make personal DSH changes in task worktrees and integrate them under the staging lock. Repository instructions still apply. - -## Find staging - -Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. - -1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to reach the source checkout, then ask Git for everything else. The `dsh` on PATH is a symlink, usually through a stable `current` symlink into the active staging worktree; resolve the chain physically and take the launcher's parent directory as the checkout. Derive the rest from that checkout rather than from any path convention: `git -C rev-parse --show-toplevel` confirms the checkout root, and `git -C rev-parse --git-common-dir` gives the shared git directory — a linked worktree reports the real clone's, not its own — whose parent is the main clone, the one real clone whose object store every worktree shares. `--git-common-dir` answers relatively for a plain clone, so anchor it against the checkout before use, and resolve it physically: Git reports resolved paths, so comparing one against an unresolved path silently misidentifies the clone whenever a symlink sits anywhere above the checkout, which a symlinked home directory alone is enough to cause. `git -C
worktree list` then enumerates every checkout sharing it. - - This resolves every checkout, so depend on nothing else: not an environment variable, not a container path, not the main clone's location or branch. A checkout whose launcher links straight at it, with no `current` in the chain, resolves the same way. -3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. -4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the main clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the main clone, or a non-staging branch. - -## Customize - -1. Create a fresh task branch and worktree from the recorded staging tip, using the repository-required worktree location — default to `.worktrees/` under the repository root unless the repository requires otherwise. Never implement or commit directly on staging. -2. Implement the change, then select and run the repository-required review and checks. If a check fails, fix the cause and rerun it before integration. -3. Test assembled interactive behavior in the Web UI; unit tests and snapshots alone are insufficient. -4. Record the task tip and confirm the task worktree is clean before integration. - -## Integrate under the lock - -1. Resolve the worktree that owns staging and use `/.agents/merge.lock`. Keep it Git-ignored; never remove or replace it. Require `flock`. -2. Acquire the lock, then re-check branch ownership, exact staging tip, clean status, and absence of an in-progress Git operation. If staging moved, unlock and restart discovery against its current owner's lock. -3. Hold the same lock through final precondition checks, `git merge --no-ff`, required post-merge checks, conflict handling, and rollback. -4. If the merge or a post-merge check fails, abort the merge or restore the recorded clean staging tip before unlocking. Never discard unknown user files. -5. Before unlocking, verify staging's branch, commit, clean status, and required checks. Report that evidence and the commands run. -6. Remove the task worktree and branch only when their commits are reachable from staging and no longer needed. - -Use [`dsh-upstream-customization`](../dsh-upstream-customization/SKILL.md) when the user wants to contribute a personal feature upstream. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md deleted file mode 100644 index 119725274c..0000000000 --- a/skills/dsh-upgrade/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: dsh-upgrade -description: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH. ---- - -# DSH Upgrade - -Prepare and validate the upgrade in a fresh staging worktree of the main clone, leave the worktree the installed launcher currently uses unchanged, then atomically repoint the stable `current` symlink once. Read and follow [`dsh-customize`](../dsh-customize/SKILL.md) before starting; it owns checkout discovery and lock handling. - -## Layout - -Resolve the layout, never assume it. [`dsh-customize`](../dsh-customize/SKILL.md) owns the procedure: follow the PATH launcher to the staging worktree, then derive the main clone from that checkout with Git. One resolution covers every checkout, so this workflow needs no special case and depends on no environment variable. - -The resolved layout is one container directory `` holding each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`, plus the stable symlink `/current` pointing at the active one; the PATH launcher links to `/current/bin/dsh`, so it resolves PATH -> `current` -> staging worktree. The main clone is the one real clone whose object store every worktree shares, and is never a launcher target. It may live inside `` or anywhere else on disk, on any branch, with remotes that may point at a fork — so treat it strictly as the object store and worktree host, and take authoritative upstream from step 1 instead. Cutover repoints `current` alone, so the PATH launcher itself never moves. The main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. When the launcher links straight at a worktree with no `current` in the chain, the same resolution finds it, and cutover creates `current` and repoints PATH to `current/bin/dsh` as a one-time migration. - -## Names - -One upgrade attempt uses one UTC basic timestamp `YYYYMMDDTHHMMSSZ` for all names: - -- new staging worktree: `/staging-`; -- preparation branch: `dsh-upgrade/prepare-`; -- installed staging branch: `dsh-staging/`; -- fetched upstream ref: `refs/dsh-upgrade/upstream-`; -- recovery ref: `refs/dsh-upgrade/recovery-`; -- recorded `current` target before cutover: the old staging worktree path, kept for symlink rollback. - -The worktree name is always `staging-` under ``, never derived from the current staging directory name, so successive upgrades stay in one place and do not accumulate timestamps. The preparation branch and private refs are local-only and must never be pushed. Before starting, reject a current staging branch named exactly `dsh-staging`, because Git cannot also create `dsh-staging/`; require the user to choose a non-conflicting staging namespace rather than silently renaming it. If the new staging worktree path exists, resume only when it is a clean worktree of this main clone whose recorded old tip, upstream ref, recovery ref, and named branches exactly match this attempt; otherwise stop. Never add an ad hoc suffix or delete an unknown directory. - -## Upgrade - -1. Resolve the installed launcher, its staging worktree and branch, the main clone, the current DSH process source, and authoritative upstream. Record exact tips, paths, clean status, remotes, dependencies, worktrees, and in-progress Git operations. Require the installed staging worktree to be clean and its `.agents/merge.lock` to exist and be Git-excluded. Never stash automatically. -2. Treat the staging worktree behind the installed launcher as immutable for the whole attempt: do not touch its branch, HEAD, index, tracked or untracked files, dependencies, worktree registration, or lock file. Fetching into the shared main clone and creating new branches, worktrees, and private refs there are allowed because they are append-only and never alter the old worktree's checkout; opening and holding the existing lock is the only operation on the old worktree. -3. Allocate the timestamp and new staging worktree path. Acquire the installed worktree's existing `.agents/merge.lock`, repeat every precondition, and keep it through preparation, validation, and the `current` cutover. If staging moves while waiting, unlock and restart with a new timestamp; remove only attempt artifacts that this run created and verified as disposable. -4. In the main clone, create `refs/dsh-upgrade/recovery-` at the recorded old staging tip and `dsh-upgrade/prepare-` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-` and record its object ID. Add a fresh worktree `/staging-` checked out on the preparation branch. Confirm the main clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. -5. Inspect the Git log and commit ranges between the staging base, old staging tip, and fetched upstream tip. Identify incoming upstream changes, personal commits to preserve, likely duplicates, and conflict-prone areas before rebasing. -6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds current, independently useful behavior or rules absent upstream. Abort without changing the installed launcher when resolution is uncertain. -7. Install dependencies in the new worktree, review the resulting diff, and run the repository-required checks. Fix failures and rerun affected checks. Test the new worktree's `bin/dsh` directly. -8. Point `dsh-staging/` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared main-clone exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. -9. Recheck the old worktree, existing lock, launcher, `current`, main clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the main clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. -10. On failure before the `current` cutover, leave `current`, the launcher, and the old worktree unchanged and remove only verified attempt artifacts created by this run (including the new worktree registration if empty). On failure during or after cutover, inspect `current`'s observed target before acting; if cutover did not verify, atomically repoint `current` back to its recorded pre-cutover target with `ln -sfn` and verify that `dsh` starts from the unchanged old staging worktree. This rollback is the sole exception allowing `current` to return to the old staging worktree. Never retry a side-effecting operation blindly. -11. Release the old worktree's lock and tell the user to restart DSH through the installed launcher. The current process may continue from the old worktree, but no operation may mutate or remove it until the restarted process proves that it runs from `dsh-staging/` and the user confirms stability. Avoid customization integration during this confirmation window; if rollback is required after new work lands, reconcile that work explicitly rather than silently stranding it. -12. After confirmation, remove the preparation branch if no process uses it. Keep the old staging worktree and branch, the recovery ref, and the recorded pre-cutover `current` target as rollback until the user explicitly approves their removal; leave the actual `git worktree remove` and directory deletion to the user. Report old, upstream, prepared, and new staging commits; both staging worktree paths and branches; the main clone path; process-source evidence; the `current` pre-cutover target and cutover; commands and checks; final status; recovery ref; and retained rollback artifacts. - -The installed launcher always resolves through `current` to a staging worktree, never the main clone. Upgrade preparation adds a new worktree that shares the main clone's object store while leaving the old worktree's checkout untouched; cutover is one atomic `current` repoint to the separately validated timestamped staging worktree, and the PATH launcher never moves. - -## Recommend upstream candidates - -After a successful upgrade, load [`dsh-upstream-customization`](../dsh-upstream-customization/SKILL.md) and classify each remaining personal customization by its rules. For each candidate, explain its classification and upstream value and recommend whether to propose it, then ask which named candidate, if any, the user wants to upstream. The answer selects a candidate to start that skill's publication workflow; it is not publishing approval, which that workflow still requires. diff --git a/skills/dsh-upstream-customization/SKILL.md b/skills/dsh-upstream-customization/SKILL.md deleted file mode 100644 index 7ec1618db3..0000000000 --- a/skills/dsh-upstream-customization/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: dsh-upstream-customization -description: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing. ---- - -# DSH Upstream Customization - -Classify and propose personal customizations upstream one feature at a time. - -## Classify - -- **Definitely propose:** bug fixes. -- **Propose:** additive, non-conflicting features implemented as plugins; visual improvements. -- **Do not propose without maintainer approval:** intrusive changes that alter existing architecture, core behavior, or broad contracts. -- Explain the classification and upstream value. If unsure whether a change is intrusive, treat it as intrusive. - -Classification and a recommendation are not publishing approval. Obtain explicit user approval naming one feature before pushing or opening a PR; approval for another feature, an upgrade, or local integration does not apply. - -## Publish an approved feature - -1. Fetch current upstream `master`, then rebuild only the approved feature on a fresh branch and worktree at that exact commit. Never publish the personal staging branch or unrelated customizations. -2. Follow repository instructions for implementation, review, testing, disclosure, PR writing, and pre-push checks. Fix failures and rerun the affected checks before publishing. -3. Review the outgoing commits and diff against upstream. Confirm they contain only the approved feature, no credentials or personal data, and a clean worktree. -4. Reconfirm the approved feature name and publishing target before the first push. Do not infer authorization from earlier local work. -5. Push only that branch and open only a draft PR. Keep its description synchronized with later changes. -6. For a Web UI feature, attach a screenshot or GIF from the assembled application after removing credentials and personal data. -7. Report the upstream base and branch commits, commands and checks run, pushed branch, and draft PR URL. From ee9465e4b32abf81550ffb7d95af74c7c7d019d6 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 16:01:12 +0800 Subject: [PATCH 51/77] build before source dsh launch --- .../2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml | 4 ++-- .../2026-07-29-dsh-source-launch-tsx-esm.md | 6 +++--- .../2026-07-29-dsh-source-launch-tsx-esm.zh.md | 6 +++--- .../2026-07-20-dsh-cli-personal-config.i18n.yaml | 4 ++-- .../feature/2026-07-20-dsh-cli-personal-config.md | 4 ++-- .../feature/2026-07-20-dsh-cli-personal-config.zh.md | 4 ++-- .../2026-08-08-remove-cli-demo.i18n.yaml | 4 ++-- .../simplification/2026-08-08-remove-cli-demo.md | 2 +- .../simplification/2026-08-08-remove-cli-demo.zh.md | 2 +- ...10-source-run-without-managed-installer.i18n.yaml | 4 ++-- ...026-08-10-source-run-without-managed-installer.md | 4 ++-- ...-08-10-source-run-without-managed-installer.zh.md | 4 ++-- AGENTS.md | 2 +- README.i18n.yaml | 4 ++-- README.md | 12 ++++++------ README.zh.md | 12 ++++++------ apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 ++-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/tests/source-launch.compat.spec.ts | 4 ++-- apps/web/tests/smoke-real.e2e.ts | 2 +- apps/web/vite.config.ts | 4 ++-- docs/api-gateway.i18n.yaml | 4 ++-- docs/api-gateway.md | 10 ++-------- docs/api-gateway.zh.md | 10 ++-------- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/user/develop/basic/index.i18n.yaml | 4 ++-- docs/user/develop/basic/index.md | 2 +- docs/user/develop/basic/index.zh.md | 2 +- docs/user/develop/basic/tool.i18n.yaml | 4 ++-- docs/user/develop/basic/tool.md | 2 +- docs/user/develop/basic/tool.zh.md | 2 +- docs/user/guide/providers.i18n.yaml | 4 ++-- docs/user/guide/providers.md | 2 +- docs/user/guide/providers.zh.md | 2 +- docs/user/guide/quickstart.i18n.yaml | 4 ++-- docs/user/guide/quickstart.md | 4 ++-- docs/user/guide/quickstart.zh.md | 4 ++-- examples/headless-agent/README.i18n.yaml | 4 ++-- examples/headless-agent/README.md | 4 ++-- examples/headless-agent/README.zh.md | 4 ++-- package.json | 4 +--- packages/boot/app-boot/README.i18n.yaml | 4 ++-- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/support/llm-mock-server/README.i18n.yaml | 4 ++-- packages/support/llm-mock-server/README.md | 2 +- packages/support/llm-mock-server/README.zh.md | 2 +- .../request-response.expected.json | 8 ++++---- 54 files changed, 99 insertions(+), 113 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index a0d0c2adf9..92744d0ead 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.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-29-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: e1016e726c32c27104b7cfe258c412502c277948 -2026-07-29-dsh-source-launch-tsx-esm.zh.md: 2dcb7f1c24f4f73d2c259e8d44ca0f63226bdbaf +2026-07-29-dsh-source-launch-tsx-esm.md: b2428602a780f2880f0f803ba59b16a76b39790e +2026-07-29-dsh-source-launch-tsx-esm.zh.md: 866bc8886725788e6e619f9507f5c8ccab63f82d diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index e1016e726c..b2428602a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -14,11 +14,11 @@ Startup latency also mattered: the off-thread `module.register()` hooks worker s ## Decision -The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh`/`demo:headless`/`demo:web` scripts use the same vector from the repository root. The CJS hook stays off because the CLI source graph is ESM-only; measured TUI time-to-banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain. +The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh` script completes the repository build, then uses that vector from the repository root. The CJS hook stays off because the CLI source graph is ESM-only; measured runtime launch to the TUI banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain. `scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-tasks` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; since fixed.) -The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`. +The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production runtime vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`. ## Alternatives considered @@ -35,4 +35,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (` - One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line. - TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode. - The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures. -- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh --profile headless` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path). +- Runtime launch improves ~0.4s over the full tsx default; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path. diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index 2dcb7f1c24..866bc88867 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -14,11 +14,11 @@ Status: implemented ## 决策 -`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh`/`demo:headless`/`demo:web` 脚本从仓库根目录使用同一启动方式。CJS 钩子保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 +`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh` 脚本先完成仓库构建,然后从仓库根目录使用同一启动方式。CJS 钩子保持关闭,因为 CLI(命令行界面)源码图是纯 ESM;实测运行时启动至 TUI banner 耗时约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 `scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader「仅为已声明运行时依赖映射 workspace import」的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest(元数据清单)走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;后已修复。) -node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块钩子或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。 +node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产运行时启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块钩子或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。 ## 备选方案 @@ -35,4 +35,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(` - 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。 - TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。 - 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。 -- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh --profile headless` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 +- 运行时启动相比完整 tsx 默认形态快约 0.4s;ACP(Agent Client Protocol)保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 57af8f4ed0..9d13851a0d 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.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-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: 4d2ed7de3357a3eb54f066d3ba4eff2fb38a1a4a -2026-07-20-dsh-cli-personal-config.zh.md: 14964beecfcd7fcd699236cb7fce4c8cc7684514 +2026-07-20-dsh-cli-personal-config.md: ed04725e92848bbab550a27ef2f4c021536f765e +2026-07-20-dsh-cli-personal-config.zh.md: cc97987f803f7fb513e94ce0ce079558f5e3dc75 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index 4d2ed7de33..ed04725e92 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -14,7 +14,7 @@ The entry modes and the personal file's name and location below are superseded b Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443): -**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` is the product-assembly tier over `packages/*` libraries. One bin dispatches the default interactive TUI, `-p`/`--prompt` headless turns, and the `web` surface. The TUI boots `examples/tui-agent/cordis.yml` (or `--config`) with the invoking directory as the workspace. From a source checkout, the root `pnpm run dsh` script runs the same entry with tsx's ESM hook; the [source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns that contract. +**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` is the product-assembly tier over `packages/*` libraries. One bin dispatches the default interactive TUI, `-p`/`--prompt` headless turns, and the `web` surface. The TUI boots `examples/tui-agent/cordis.yml` (or `--config`) with the invoking directory as the workspace. From a source checkout, the root `pnpm dsh` script builds the repository and runs the same entry with tsx's ESM hook; the [source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns that contract. **Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI, Web, and headless surfaces consume its two optional files; the demo bins boot their committed trees verbatim: @@ -40,7 +40,7 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Consequences -- An installed `dsh` command can run from any directory, while source users invoke `pnpm run dsh` from the checkout; both can apply personal providers, models, installed bundle entries, and other Loader entries with no checkout edit. The behavior was verified end to end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. +- An installed `dsh` command can run from any directory, while source users invoke `pnpm dsh` from the checkout; both can apply personal providers, models, installed bundle entries, and other Loader entries with no checkout edit. The behavior was verified end to end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. - Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../../../../apps/cli/README.md#profiles) (which prints the composed tree those patches produce) are the diagnostics. - Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-paths` for `resolveDshHome`. diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md index 14964beecf..cc97987f80 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -14,7 +14,7 @@ Status: implemented 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: -**`dsh` CLI(`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。在源码检出中,根目录的 `pnpm run dsh` 脚本使用 tsx 的 ESM hook 运行同一入口;该约定由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。 +**`dsh` CLI(命令行界面;`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。在源码检出中,根目录的 `pnpm dsh` 脚本先构建仓库,再使用 tsx 的 ESM hook 运行同一入口;该约定由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。 **个人配置(`dsh-app-boot`)。** 个人 overlay 存放在 Harness home——`$DSH_HOME`,否则 `~/.dsh`——由共享的 [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md)(`@deepseek-ai/dsh-paths`)解析,与 skills、AGENTS.md 解析所依据的单一根目录相同。dsh 的 TUI、Web 和无头界面使用其中两个可选文件;各示例 bin 仍然逐字节按已提交的配置树启动: @@ -40,7 +40,7 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Consequences -- 已安装的 `dsh` 命令可从任意目录运行,源码用户则从 checkout 调用 `pnpm run dsh`;两者都无需修改 checkout 即可应用个人提供方、模型、已安装组合包的配置项和其他 Loader 配置项。该行为已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 +- 已安装的 `dsh` 命令可从任意目录运行,源码用户则从 checkout 调用 `pnpm dsh`;两者都无需修改 checkout 即可应用个人提供方、模型、已安装组合包的配置项和其他 Loader 配置项。该行为已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 - 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../../../../apps/cli/README.md#profiles)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml index fac9ae9452..e72d9beaf3 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.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/simplification/2026-08-08-remove-cli-demo.md -2026-08-08-remove-cli-demo.md: 31879e8284daf5f34af6731b09fce743f9eb5391 -2026-08-08-remove-cli-demo.zh.md: d4dfef1ba8c4d27cd667e319519cfa1ace74baef +2026-08-08-remove-cli-demo.md: f8bc09825797f2165c14a75e5841d74b2e1bc1af +2026-08-08-remove-cli-demo.zh.md: 1044dd136888e33fc9935cf4e24cd3100b41b41f diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md index 31879e8284..f8bc098257 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md @@ -12,7 +12,7 @@ The replay suites still need canonical session events to pin assembled backend b ## Decision -Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh --profile headless`; the product command owns final-text stdout, failure diagnostics on stderr, persistence, exit status, and shutdown. +Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. Source users invoke the product command through `pnpm dsh --profile headless`; it owns final-text stdout, failure diagnostics on stderr, persistence, exit status, and shutdown. `examples/headless-agent` becomes an explicit test composition. Its Loader configs mount `@deepseek-ai/dsh-agent-spine-demo`, one root agent, JSONL persistence, and checkpoint policy as separate rows instead of hiding them behind an app bundle. The support-tier `@deepseek-ai/dsh-loader-smoke` package owns the shared direct-agent turn helper; unexported example-local drivers select their Loader configuration and render canonical events as JSONL. They are launched only by tests, have no bin, and do not define a supported product output format. diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md index d4dfef1ba8..1044dd1368 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh --profile headless` 的直接别名保留;stdout 上的最终文本、stderr 上的失败诊断、持久化、退出状态和关闭行为均由产品命令负责。 +彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。源码用户通过 `pnpm dsh --profile headless` 调用产品命令;stdout 上的最终文本、stderr 上的失败诊断、持久化、退出状态和关闭行为均由该命令负责。 `examples/headless-agent` 成为显式测试组装。其 Loader 配置把 `@deepseek-ai/dsh-agent-spine-demo`、一个根 agent(智能体)、JSONL 持久化和检查点策略挂载为独立配置行,不再将其隐藏在应用组合包之后。支持层的 `@deepseek-ai/dsh-loader-smoke` 包负责共享的直接 agent 轮次 helper;未导出的示例本地 driver 选择各自的 Loader 配置,并将规范事件渲染为 JSONL。这些 driver 只由测试启动,不提供 bin,也不定义受支持的产品输出格式。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml index 13bf0cac84..90f4d0e145 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.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/simplification/2026-08-10-source-run-without-managed-installer.md -2026-08-10-source-run-without-managed-installer.md: 959b6fc6cad5113bb3ba0d17ee27985db5ece82e -2026-08-10-source-run-without-managed-installer.zh.md: 9df969f3754b967fd1b3498191b7c9c2b0a41656 +2026-08-10-source-run-without-managed-installer.md: 35f0c705731147ce8bb64c2e985789aff341996c +2026-08-10-source-run-without-managed-installer.zh.md: 51b336f4c399ebfd24f499288eee477bed9f23cb diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md index 959b6fc6ca..35f0c70573 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md @@ -12,7 +12,7 @@ That lifecycle is not required to run or develop DeepSeek Harness from a source ## Decision -The repository supports source execution through its `pnpm run` scripts. The root README directs users to clone the repository, run `pnpm install`, configure credentials through the environment or root `.env`, and launch the required interface with `pnpm run demo:web`, `pnpm run demo:headless`, `pnpm run demo:acp`, or `pnpm run dsh`. +The repository supports source execution through its root `pnpm` scripts. The `pnpm dsh ` script runs the complete repository build before launching the source CLI; users select Web with `pnpm dsh web` and headless execution with `pnpm dsh run`. The independent ACP example remains available through `pnpm run demo:acp`. The repository does not distribute a source installer, an installer test suite, or skills that assume a managed `current` symlink and timestamped staging worktrees. Users own source checkout placement, Git updates, and any launcher they create outside the repository. @@ -28,4 +28,4 @@ The repository does not distribute a source installer, an installer test suite, Source users invoke repository scripts rather than an installed `dsh` command. The repository provides no atomic upgrade cutover or preserved staging rollback checkout, and it does not automate the integration or upstream publication of personal source modifications. A future distribution mechanism must justify its ownership of installation and upgrade state, define recovery behavior, and add tests and user documentation without making the source-run path depend on it. Any future publication workflow must isolate one approved feature and obtain explicit approval before its first push and draft PR. -Verification covers repository-wide references to the removed entry points, documentation links, generated third-party-notice freshness, and a source CLI smoke through `pnpm run`. +Verification covers repository-wide references to the removed entry points, documentation links, generated third-party-notice freshness, and a clean-artifact source CLI smoke through `pnpm dsh`. diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md index 9df969f375..51b336f4c3 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -仓库通过 `pnpm run` 脚本支持从源码运行。根 README 指示用户克隆仓库、运行 `pnpm install`、通过环境变量或根目录 `.env` 配置凭证,并使用 `pnpm run demo:web`、`pnpm run demo:headless`、`pnpm run demo:acp` 或 `pnpm run dsh` 启动所需接口。 +仓库通过根目录的 `pnpm` 脚本支持从源码运行。`pnpm dsh ` 脚本会先完成整个仓库的构建,再启动源码 CLI(命令行界面);用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh run` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 仓库不分发源码安装器、安装器测试套件,也不分发依赖受管理的 `current` 符号链接和带时间戳 staging worktree 的 skill。源码检出的存放位置、Git 更新,以及用户在仓库外创建的任何启动器均由用户负责。 @@ -28,4 +28,4 @@ Status: implemented 源码用户通过仓库脚本运行程序,而非使用已安装的 `dsh` 命令。仓库不提供原子升级切换,也不保留 staging 回滚检出;仓库同样不会自动集成个人源码修改或将其发布到上游。未来的分发机制必须说明为何应由其管理安装和升级状态,定义恢复行为,并补充测试与用户文档,同时不得让源码运行路径依赖该机制。未来任何发布工作流都必须隔离出一项获批功能,并在首次推送和创建草稿 PR(Pull Request)前取得明确批准。 -验证范围包括仓库内对已移除入口点的所有引用、文档链接、生成的第三方声明文件的新鲜度,以及通过 `pnpm run` 启动源码 CLI(命令行界面)的冒烟测试。 +验证范围包括仓库内对已移除入口点的所有引用、文档链接、生成的第三方声明文件的新鲜度,以及在无构建产物状态下通过 `pnpm dsh` 启动源码 CLI 的冒烟测试。 diff --git a/AGENTS.md b/AGENTS.md index adc14858d7..09dbe67166 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (nee pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) -pnpm run demo:headless "task" # one-shot agent (needs DEEPSEEK_API_KEY) +pnpm dsh run "task" # build, then run one task (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # the agent modifies its own runtime (needs key) pnpm run demo:acp # ACP automation server (needs DEEPSEEK_API_KEY) ``` diff --git a/README.i18n.yaml b/README.i18n.yaml index dd2d183633..6bdc6f40bd 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: f929f9746864857fdaf2313f0d5b63d3647adab5 -README.zh.md: 8886016f8afdcd710715a9f4178125e3af9fc788 +README.md: 95db4d2c024db27da9f019d213a5ecb93278bbf7 +README.zh.md: 885f8194a138995fd4eeb57e7ce042e9170e0e58 diff --git a/README.md b/README.md index f929f97468..95db4d2c02 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ cd deepseek-harness pnpm install ``` -Set `DEEPSEEK_API_KEY` in the environment or the repository's root `.env`. Run DeepSeek Harness from this checkout through the `pnpm run` commands below. +Set `DEEPSEEK_API_KEY` in the environment or the repository's root `.env`. Run DeepSeek Harness from this checkout through the root `pnpm` scripts below. ## Use DeepSeek Harness @@ -31,7 +31,7 @@ Set `DEEPSEEK_API_KEY` in the environment or the repository's root `.env`. Run D Start the recommended local interface from the repository root: ```sh -pnpm run demo:web +pnpm dsh web ``` The command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default. @@ -41,9 +41,9 @@ The command builds the repository before starting the Web UI, which is served at The source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`: ```sh -pnpm run dsh --profile web # the browser UI -pnpm run dsh plugin --profile tui add # install a plugin into a custom profile -pnpm run dsh --profile tui # boot it +pnpm dsh --profile web # the browser UI +pnpm dsh plugin --profile tui add # install a plugin into a custom profile +pnpm dsh --profile tui # boot it ``` The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands. @@ -53,7 +53,7 @@ The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -pnpm run demo:headless "summarize this workspace" +pnpm dsh run "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index 8886016f8a..885f8194a1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -22,7 +22,7 @@ cd deepseek-harness pnpm install ``` -请通过环境变量或仓库根目录的 `.env` 设置 `DEEPSEEK_API_KEY`。使用下方 `pnpm run` 命令从该检出运行 DeepSeek Harness。 +请通过环境变量或仓库根目录的 `.env` 设置 `DEEPSEEK_API_KEY`。使用下方根目录 `pnpm` 脚本从该检出运行 DeepSeek Harness。 ## 使用 DeepSeek Harness @@ -31,7 +31,7 @@ pnpm install 请从仓库根目录启动推荐的本地界面: ```sh -pnpm run demo:web +pnpm dsh web ``` 该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 @@ -41,9 +41,9 @@ pnpm run demo:web 源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层: ```sh -pnpm run dsh --profile web # the browser UI -pnpm run dsh plugin --profile tui add # install a plugin into a custom profile -pnpm run dsh --profile tui # boot it +pnpm dsh --profile web # the browser UI +pnpm dsh plugin --profile tui add # install a plugin into a custom profile +pnpm dsh --profile tui # boot it ``` profile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。 @@ -53,7 +53,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -pnpm run demo:headless "summarize this workspace" +pnpm dsh run "summarize this workspace" ``` ### 自动化与 SDK diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index db6bb56a41..191d6c8e68 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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/README.md -README.md: 082c0b98c08a26fd0468b770c3a9b7332bc5bcfd -README.zh.md: a3a983939ed6fef02c4929dde957762d1fae4c64 +README.md: 98a856261bc632c97f350db8fc7bb0b10c22235d +README.zh.md: 283e54138e24202ed6b88d1d309538c58cd66b3e diff --git a/apps/cli/README.md b/apps/cli/README.md index 082c0b98c0..98a856261b 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -35,4 +35,4 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f ## Development -Production runs require built package and frontend artifacts. From the repository root, use `pnpm run dsh ` to run the TypeScript entry and forward arguments; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. +Production runs require built package and frontend artifacts. From the repository root, `pnpm dsh ` builds those artifacts, runs the TypeScript entry, and forwards every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index a3a983939e..283e54138e 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -35,4 +35,4 @@ profile 目录包含一个 `package.json`(树外插件依赖,加上 profile ## 开发 -生产运行需要已构建的包与前端产物。请从仓库根目录使用 `pnpm run dsh ` 运行 TypeScript 入口并转发参数;模块解析约定由[源码执行参考](reference/README.md#source-execution)负责。 +生产运行需要已构建的包与前端产物。从仓库根目录运行 `pnpm dsh ` 会先构建这些产物,再运行 TypeScript 入口并转发所有参数;模块解析约定由[源码执行参考](reference/README.md#source-execution)负责。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index b98a541986..9bef07acad 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: e9db27515cf6fa6c910bc684f0250da0a189d335 -README.zh.md: a30fab8da26056956d1f8cad7c73e7cb39287b85 +README.md: e4cab19a79416d6957a3bf5b6e855ee60473b1a0 +README.zh.md: cd58506b9936b3fb6cf6f8063e1bc2768262b971 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index e9db27515c..e4cab19a79 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -81,4 +81,4 @@ Install external plugin bundles through `dsh plugin --profile add `. The script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` and forwards every argument. The built form is `apps/cli/lib/bin.js` after `pnpm run build`. +From the repository root, use `pnpm dsh `. The script runs the complete repository build, launches `apps/cli/src/bin.ts` with `node --import tsx/esm`, and forwards every argument. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index a30fab8da2..cd58506b99 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -81,4 +81,4 @@ dsh web --help ## 源码执行 -请从仓库根目录使用 `pnpm run dsh `。该脚本通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。运行 `pnpm run build` 后,构建形式为 `apps/cli/lib/bin.js`。 +请从仓库根目录使用 `pnpm dsh `。该脚本会完成整个仓库的构建,通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index 29f17ac7b9..5d92d021f0 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -4,8 +4,8 @@ import { describe, expect, it } from 'vitest' /** * Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts` - * with the exact production launch vector (`node --import tsx/esm`, the same - * shape as the root `dsh` script) and assert the + * with the exact production runtime vector (`node --import tsx/esm`, the + * vector the root `dsh` script invokes after building) and assert the * required-config diagnostic. The Node compatibility matrix runs this * WHOLE file, so a Node release changing module hooks or TypeScript handling * breaks this gate instead of every developer's `pnpm dsh`; the built-bin diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index ae5805dc8a..24a6f269c3 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -478,7 +478,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke requireDist() sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-')) const port = await probeFreePort() - // tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate + // tsx boot mirrors the runtime half of the root dsh script. Isolate // the host-level Harness and shared-agent homes inside the temp world; tsx // also needs the repo's loader and tsconfig paths pointed at explicitly. const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a8323cdd78..2600d64acb 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -5,8 +5,8 @@ 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__. ' - + 'Build with `pnpm run build && pnpm run build:web`, then run `dsh web` (repository checkout: `pnpm run dsh -- web`). ' - + 'For client-plugin HMR, run `pnpm run dsh -- web --dev` together with `pnpm run dev:web`.' + + '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`.' /** 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 9f5e41be77..a958569942 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: 7c666dbc431b076262a9a93f1baefd88a29237e8 -api-gateway.zh.md: eee783def7e981dc888e1192c6e1a1f9ccd424c4 +api-gateway.md: 3065f3f10861965b327a4412049878dc1ba7faec +api-gateway.zh.md: aa9b726c33fd9f51fc0b2d2ed95c4c9658662796 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 7c666dbc43..3065f3f108 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -138,16 +138,10 @@ SRC solves only dispatch for a Host process running from source. The Client does ## Development mode -A complete build generates Host contracts before compiling the Host, Client, and Web, so it is the deterministic entry for creating or refreshing all artifacts: +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 run build -``` - -Web development normally starts the source Host after one complete build and runs the Client plugin watcher in another terminal: - -```sh -pnpm run dsh web --dev +pnpm dsh web --dev pnpm run dev:web ``` diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index eee783def7..aa9b726c33 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -138,16 +138,10 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho ## 开发模式 -完整构建会先生成 Host 约定,再编译 Host、Client 与 Web,因此是建立或刷新所有产物的确定性入口: +仓库的 `dsh` 脚本会先完成 Host、Client 与 Web 构建,再启动源码 Host。Web 开发需要在两个终端中分别运行该命令和 Client plugin watcher: ```sh -pnpm run build -``` - -Web 开发通常在完成一次构建后启动源码 Host,并在另一个终端运行 Client plugin watcher: - -```sh -pnpm run dsh web --dev +pnpm dsh web --dev pnpm run dev:web ``` diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index da29debe08..9044346ca7 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: 8e565f21c6e2ede7dab7dbda3c4b18b77ce0920f -development.zh.md: d9c0fbfbb663334b8f7e2ca11d4e8d9a0652c22e +development.md: 67e522dd183b953c511a24ee89e77892c9cef925 +development.zh.md: e6722e4254cb06551e5f97942040ddf42d3aa4e4 diff --git a/docs/development.md b/docs/development.md index 8e565f21c6..67e522dd18 100644 --- a/docs/development.md +++ b/docs/development.md @@ -129,7 +129,7 @@ The root [contributor instructions](../AGENTS.md#commands) summarize common comm The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh -pnpm run demo:headless "summarize this workspace" +pnpm dsh run "summarize this workspace" ``` The self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`): diff --git a/docs/development.zh.md b/docs/development.zh.md index d9c0fbfbb6..e6722e4254 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -129,7 +129,7 @@ keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若 单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh -pnpm run demo:headless "summarize this workspace" +pnpm dsh run "summarize this workspace" ``` 自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`): diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index bb4870f624..6684d18070 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/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/develop/basic/index.md -index.md: fe525018011809abe4dd18ac5739c54cd330c8f6 -index.zh.md: 0dbb7275be3c7147395041fb18d49a7f6ea69cdd +index.md: e57a42b42690bd92450cc26876c13a1622bb80cc +index.zh.md: 240623341618acd6501f2897ae2844fde0a5b73b diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index fe52501801..e57a42b426 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -56,7 +56,7 @@ Create `scratch-plugin/cordis.yml` as a Web overlay that inserts the local plugi Start the Web UI with that overlay: ```sh -pnpm run dsh web --patch ./scratch-plugin/cordis.yml +pnpm dsh web --patch ./scratch-plugin/cordis.yml ``` Open `http://127.0.0.1:3080`. The terminal prints `[hello-plugin] plugin loaded!` during startup. diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 0dbb7275be..2406233416 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -56,7 +56,7 @@ export function apply(ctx: Context) { 使用该覆盖层启动 Web UI: ```sh -pnpm run dsh web --patch ./scratch-plugin/cordis.yml +pnpm dsh web --patch ./scratch-plugin/cordis.yml ``` 打开 `http://127.0.0.1:3080`。启动期间,终端会打印 `[hello-plugin] plugin loaded!`。 diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index 3ddc760f7d..2fdba218d0 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/tool.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/develop/basic/tool.md -tool.md: f110bd2c10caf21ea8c87bc32fd43b01b209a9d1 -tool.zh.md: a237b6015de0ea6141a94c56aaa6f68c73fa3feb +tool.md: a149f6876c573c9ece4b066ef6c211f69b0b03a8 +tool.zh.md: 1f9a1172bcde33a1d7319492f15686c47de01174 diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index f110bd2c10..a149f6876c 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -40,7 +40,7 @@ export function apply(ctx: Context) { Restart the development command if it is not running: ```sh -pnpm run dsh web --patch ./scratch-plugin/cordis.yml +pnpm dsh web --patch ./scratch-plugin/cordis.yml ``` Open `http://127.0.0.1:3080` and ask: `Use the greet tool to greet Ada.` The model can call `greet` and receives `Hello, Ada!` as the tool result. diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index a237b6015d..1f9a1172bc 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -40,7 +40,7 @@ export function apply(ctx: Context) { 如果开发命令未在运行,请重新启动: ```sh -pnpm run dsh web --patch ./scratch-plugin/cordis.yml +pnpm dsh web --patch ./scratch-plugin/cordis.yml ``` 打开 `http://127.0.0.1:3080`,然后输入:`Use the greet tool to greet Ada.` 模型可以调用 `greet`,并收到 `Hello, Ada!` 这一工具结果。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index c95a0db6c0..9a702c61e3 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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/guide/providers.md -providers.md: 4667e54161e77a62d454f3f78a8164d5c79b78c8 -providers.zh.md: 15e0ccc826a5c285c71d4776793f8048c11f6254 +providers.md: 31b4be666bd6b7a402a0bae7bc8f810fd2d928d8 +providers.zh.md: bd95c63cacd9718b3ffc1f78e17270e7022b21e0 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 4667e54161..31b4be666b 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -15,7 +15,7 @@ Adding a provider therefore rarely means editing `cordis.yml` — writing settin ## Configure from the web UI -Start `pnpm run dsh web` and open **Settings → Models**. +Start `pnpm dsh web` and open **Settings → Models**. ![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png) diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 15e0ccc826..bd95c63cac 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -15,7 +15,7 @@ Harness 出厂自带 DeepSeek,同时预装了一个通用的多提供方适配 ## 在 Web 界面里配置 -启动 `pnpm run dsh web`,打开**设置 → 模型**。 +启动 `pnpm dsh web`,打开**设置 → 模型**。 ![模型页:DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png) diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index f31426730a..83eea0ed76 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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/guide/quickstart.md -quickstart.md: 8e883efd470fef329a7308f1017cab0b7afcdd67 -quickstart.zh.md: e0e3f4e4754bca588831b6d204f77d1c97a140b3 +quickstart.md: 540eed86bc83e70f3a9724f601fd71170996ce0b +quickstart.zh.md: ab529333eb3c62add8cf422328b0ba838efc82e6 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 8e883efd47..540eed86bc 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -36,7 +36,7 @@ DEEPSEEK_API_KEY=sk-your-key-here Run a non-interactive task and print its final answer: ```sh -pnpm run dsh --profile headless "summarize the architecture of this workspace" +pnpm dsh --profile headless "summarize the architecture of this workspace" ``` `dsh --profile headless` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty. @@ -46,7 +46,7 @@ pnpm run dsh --profile headless "summarize the architecture of this workspace" Start the browser interface: ```sh -pnpm run dsh web +pnpm dsh web ``` Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, delegate subtasks, and track a plan. Try: `Create hello.js in the current directory, print "Hello from Harness!", and run it`. diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index e0e3f4e475..ab529333eb 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -36,7 +36,7 @@ DEEPSEEK_API_KEY=sk-your-key-here 运行一个非交互式任务并打印最终回答: ```sh -pnpm run dsh --profile headless "summarize the architecture of this workspace" +pnpm dsh --profile headless "summarize the architecture of this workspace" ``` `dsh --profile headless` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 @@ -46,7 +46,7 @@ pnpm run dsh --profile headless "summarize the architecture of this workspace" 启动浏览器界面: ```sh -pnpm run dsh web +pnpm dsh web ``` 打开 `http://127.0.0.1:3080`。agent 可以读写文件、运行命令、分配子任务和跟踪计划。可以尝试:`Create hello.js in the current directory, print "Hello from Harness!", and run it`。 diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index 0871ba2c15..e965ffee3a 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/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 examples/headless-agent/README.md -README.md: 08b36e9c8e558db3b30175b20bd6e0af160ccef5 -README.zh.md: 40d11ebeeb1631af1421b3c013a5e548fd3fd488 +README.md: 2f854924d4bfd2bf66b3d6f47433136098d7b762 +README.zh.md: 4128645da8a0f8a84b17d819fc614051b68f2a11 diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index 08b36e9c8e..2f854924d4 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -10,10 +10,10 @@ This directory owns the replay and real-model test composition for a headless co # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run dsh --profile headless "fix the failing test in this workspace" +pnpm dsh --profile headless "fix the failing test in this workspace" ``` -The product command is [`dsh --profile headless`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. The root `demo:headless` script is only an alias of that command. +The product command is [`dsh --profile headless`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. Snapshot suites run this directory's configuration through [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts), an unexported test-only process that emits canonical session events as JSONL before its result record. That stream is test infrastructure, not a supported CLI output format. Child sessions surface only through parent tool events and results. diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md index 40d11ebeeb..4128645da8 100644 --- a/examples/headless-agent/README.zh.md +++ b/examples/headless-agent/README.zh.md @@ -10,10 +10,10 @@ # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run dsh --profile headless "fix the failing test in this workspace" +pnpm dsh --profile headless "fix the failing test in this workspace" ``` -产品命令是 [`dsh --profile headless`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。根目录的 `demo:headless` 脚本只是该命令的别名。 +产品命令是 [`dsh --profile headless`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。 快照套件通过 [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts) 运行本目录的配置。这个未导出且仅供测试使用的进程会在结果记录之前,以 JSONL 发出规范会话事件。该事件流属于测试基础设施,不是受支持的 CLI(命令行界面)输出格式。子会话只通过父会话的工具事件和结果对外显示。 diff --git a/package.json b/package.json index d99acadf7a..4acd724112 100644 --- a/package.json +++ b/package.json @@ -127,12 +127,10 @@ "release:pack": "tsx scripts/release/pack.ts", "release:verify-packed-install": "tsx scripts/release/verify-packed-install.ts", "release:publish": "tsx scripts/release/publish.ts", - "dsh": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts --profile headless", + "dsh": "npm run build && node --import tsx/esm apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", - "demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index cd80f4b8eb..efd6a9c844 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/app-boot/README.md -README.md: 9880a4789b85f516ed16ead293ebb5c10da0d57f -README.zh.md: b070696e5195feb663ad44605f55553eac672656 +README.md: 9639f1c0a2ffe91fd509a2ffdf04be5f0895b700 +README.zh.md: e8bf0374aad2be2311b6e72e91e41f02f403b48a diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 9880a4789b..9639f1c0a2 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -29,7 +29,7 @@ The Loader mounts entries concurrently, so a surface can already own the termina `cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all. -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `pnpm run dsh` source path additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `pnpm dsh` source path additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index b070696e51..e8bf0374aa 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -29,7 +29,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 `cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`pnpm run dsh` 源码路径还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`pnpm dsh` 源码路径还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 diff --git a/packages/support/llm-mock-server/README.i18n.yaml b/packages/support/llm-mock-server/README.i18n.yaml index c78f65cc91..48cd93b989 100644 --- a/packages/support/llm-mock-server/README.i18n.yaml +++ b/packages/support/llm-mock-server/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/support/llm-mock-server/README.md -README.md: a535c086bf688ad48b1a3bb19c7b81da21cbad92 -README.zh.md: d814d8815b38bb34bd0d871d552e6f3ec75e042a +README.md: f08c70d90b100e1c8d7f93e5e6a7e4cc30705c02 +README.zh.md: 8a3252ebac46de1494546a5a41acf13ff443f12d diff --git a/packages/support/llm-mock-server/README.md b/packages/support/llm-mock-server/README.md index a535c086bf..f08c70d90b 100644 --- a/packages/support/llm-mock-server/README.md +++ b/packages/support/llm-mock-server/README.md @@ -23,7 +23,7 @@ Point the shipping DeepSeek adapter at the server; it appends `/chat/completions ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ DEEPSEEK_API_KEY=mock-key \ -pnpm run demo:headless "test provider recovery" +pnpm dsh run "test provider recovery" ``` The repository script writes JSONL to stdout: a `ready` record carries the `/v1` base URL and random seed, followed by request/result records that name both the scripted behavior and the concrete selected behavior. The private support package exposes no installable binary. diff --git a/packages/support/llm-mock-server/README.zh.md b/packages/support/llm-mock-server/README.zh.md index d814d8815b..8a3252ebac 100644 --- a/packages/support/llm-mock-server/README.zh.md +++ b/packages/support/llm-mock-server/README.zh.md @@ -23,7 +23,7 @@ pnpm run mock:llm -- \ ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ DEEPSEEK_API_KEY=mock-key \ -pnpm run demo:headless "test provider recovery" +pnpm dsh run "test provider recovery" ``` 仓库脚本将 JSONL 写入 stdout:`ready` 记录携带以 `/v1` 结尾的基础 URL 和随机种子,后续请求/结果记录同时命名脚本行为和实际选中的具体行为。这个私有支持包不公开可安装的二进制命令。 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index db389e48a0..2929594fb8 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,19 +8,19 @@ }, { "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\nInstall `git`, Node `^22.19 || >=24`, and Corepack-enabled `pnpm`, then prepare a source checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness-sdk.git\ncd deepseek-harness\npnpm install\n```\n\nSet `DEEPSEEK_API_KEY` in the environment or the repository's root `.env`. Run DeepSeek Harness from this checkout through the `pnpm run` commands below.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm run demo: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 run dsh --profile web # the browser UI\npnpm run dsh plugin --profile tui add # install a plugin into a custom profile\npnpm run 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 run demo: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\nInstall `git`, Node `^22.19 || >=24`, and Corepack-enabled `pnpm`, then prepare a source checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness-sdk.git\ncd deepseek-harness\npnpm install\n```\n\nSet `DEEPSEEK_API_KEY` in the environment or the repository's root `.env`. Run DeepSeek Harness from this checkout through the root `pnpm` scripts below.\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 run \"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" }, { "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请安装 `git` 和 Node `^22.19 || >=24`,并通过 Corepack 启用 `pnpm`,然后准备源码检出:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness-sdk.git\ncd deepseek-harness\npnpm install\n```\n\n请通过环境变量或仓库根目录的 `.env` 设置 `DEEPSEEK_API_KEY`。使用下方 `pnpm run` 命令从该检出运行 DeepSeek Harness。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm run demo: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 run dsh --profile web # the browser UI\npnpm run dsh plugin --profile tui add # install a plugin into a custom profile\npnpm run dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm run demo: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请安装 `git` 和 Node `^22.19 || >=24`,并通过 Corepack 启用 `pnpm`,然后准备源码检出:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness-sdk.git\ncd deepseek-harness\npnpm install\n```\n\n请通过环境变量或仓库根目录的 `.env` 设置 `DEEPSEEK_API_KEY`。使用下方根目录 `pnpm` 脚本从该检出运行 DeepSeek Harness。\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 run \"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" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm dsh run \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm dsh run \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From c9e14307b829816fac821bf76acbafcc2e1dfeb5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 16:56:34 +0800 Subject: [PATCH 52/77] docs: simplify source-run instructions --- README.i18n.yaml | 4 ++-- README.md | 8 ++------ README.zh.md | 8 ++------ .../translation-prompt-v4/request-response.expected.json | 4 ++-- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/README.i18n.yaml b/README.i18n.yaml index 6bdc6f40bd..12b9377e4a 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: 95db4d2c024db27da9f019d213a5ecb93278bbf7 -README.zh.md: 885f8194a138995fd4eeb57e7ce042e9170e0e58 +README.md: 98091a2b4468bebe6e571d66c06c8e343b1595ea +README.zh.md: 3842ac35c4fbd5c51833cb20e8e079cbbb641e7a diff --git a/README.md b/README.md index 95db4d2c02..98091a2b44 100644 --- a/README.md +++ b/README.md @@ -14,16 +14,12 @@ The internal build uploads all Session Logs by default to help diagnose reported ## Run from source -Install `git`, Node `^22.19 || >=24`, and Corepack-enabled `pnpm`, then prepare a source checkout: +Clone this repo, then run: ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git -cd deepseek-harness -pnpm install +pnpm dsh web ``` -Set `DEEPSEEK_API_KEY` in the environment or the repository's root `.env`. Run DeepSeek Harness from this checkout through the root `pnpm` scripts below. - ## Use DeepSeek Harness ### Web UI diff --git a/README.zh.md b/README.zh.md index 885f8194a1..3842ac35c4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -14,16 +14,12 @@ DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化 ## 从源码运行 -请安装 `git` 和 Node `^22.19 || >=24`,并通过 Corepack 启用 `pnpm`,然后准备源码检出: +克隆本仓库,然后运行: ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git -cd deepseek-harness -pnpm install +pnpm dsh web ``` -请通过环境变量或仓库根目录的 `.env` 设置 `DEEPSEEK_API_KEY`。使用下方根目录 `pnpm` 脚本从该检出运行 DeepSeek Harness。 - ## 使用 DeepSeek Harness ### Web UI diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 2929594fb8..a9af47fb9b 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\nInstall `git`, Node `^22.19 || >=24`, and Corepack-enabled `pnpm`, then prepare a source checkout:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness-sdk.git\ncd deepseek-harness\npnpm install\n```\n\nSet `DEEPSEEK_API_KEY` in the environment or the repository's root `.env`. Run DeepSeek Harness from this checkout through the root `pnpm` scripts below.\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 run \"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, then run:\n\n```sh\npnpm dsh ...\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 run \"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" }, { "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请安装 `git` 和 Node `^22.19 || >=24`,并通过 Corepack 启用 `pnpm`,然后准备源码检出:\n\n```sh\ngit clone https://github.com/deepseek-ai/deepseek-harness-sdk.git\ncd deepseek-harness\npnpm install\n```\n\n请通过环境变量或仓库根目录的 `.env` 设置 `DEEPSEEK_API_KEY`。使用下方根目录 `pnpm` 脚本从该检出运行 DeepSeek Harness。\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 run \"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克隆本仓库,然后运行:\n\n```sh\npnpm dsh ...\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 run \"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" }, { "role": "user", From 7e77fcb52b6a9616bda6b39782a8a8524a7f5f59 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 17:44:55 +0800 Subject: [PATCH 53/77] fix(ci): stabilize snapshot checks --- apps/web/tests/workspace-management.e2e.ts | 46 ++++++++++++------- .../request-response.expected.json | 4 +- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index c18a522d30..a435592da5 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -99,6 +99,19 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff } } + /** + * Reveal and click a row action, re-hovering if a projection update replaces + * the row before its hover-only button becomes visible. + */ + async function clickHoverAction(row: Locator, name: string): Promise { + const button = row.getByRole('button', { name }) + await expect.poll(async () => { + await row.hover() + return await button.isVisible() + }, { timeout: 10_000 }).toBe(true) + await button.click() + } + beforeAll(async () => { scaffold = await launchWebScaffold({}) // Seed one cold session (Ungrouped bucket) for the flat view + hover card. @@ -137,10 +150,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff it('renames a workspace over the wire with a duplicate-name pre-check', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-rename')) - // The actions button is display:none until its row hovers — hover the - // group row first, then the revealed button becomes actionable. - await page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first().hover() - await page.getByRole('button', { name: 'Workspace actions for alpha-ws' }).click() + const alphaRow = page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first() + await clickHoverAction(alphaRow, 'Workspace actions for alpha-ws') await page.getByRole('menuitem', { name: 'Rename' }).click() const dialog = page.getByRole('dialog', { name: 'Rename workspace' }) await dialog.waitFor({ timeout: 10_000 }) @@ -214,17 +225,19 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff // The header row is wrapped by its HoverCard anchor span, so the section // is the nearest groupSection ancestor, not the immediate parent. const groupSection = groupRow.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]') - if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click() - await expect.poll( - () => groupSection.locator('[role="treeitem"]').count(), - { timeout: 10_000 }, - ).toBeGreaterThanOrEqual(2) + await expect.poll(async () => { + const count = await groupSection.locator('[role="treeitem"]').count() + if (count < 2 && await groupRow.getAttribute('aria-expanded') !== 'true') { + await groupRow.click() + await page.waitForTimeout(50) + } + return await groupSection.locator('[role="treeitem"]').count() + }, { timeout: 10_000 }).toBeGreaterThanOrEqual(2) const seededRow = groupSection.locator('[role="treeitem"]').nth(1) await seededRow.click() await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true') - await groupRow.hover() - await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click() + await clickHoverAction(groupRow, `Workspace actions for ${workspace.title}`) await page.getByRole('menuitem', { name: 'Delete workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Delete workspace' }) await dialog.waitFor({ timeout: 10_000 }) @@ -338,8 +351,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered') const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first() - await oldRow.hover() - await page.getByRole('button', { name: `Workspace actions for ${title}` }).click() + await clickHoverAction(oldRow, `Workspace actions for ${title}`) await page.getByRole('menuitem', { name: 'Delete workspace' }).click() await page.getByRole('dialog', { name: 'Delete workspace' }) .getByRole('button', { name: 'Delete workspace' }).click() @@ -509,9 +521,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu')) const sessionRow = await seededSessionRow() // The trigger is display:none until its row hovers. - await sessionRow.hover() const trigger = sessionRow.locator('button[aria-label^="Session actions for "]') - await trigger.click() + const triggerName = await trigger.getAttribute('aria-label') + if (triggerName === null) throw new Error('seeded Session row has no actions label') + await clickHoverAction(sessionRow, triggerName) const item = page.getByRole('menuitem', { name: 'Rename' }) await item.waitFor({ timeout: 5_000 }) // Into the list, then back up to the trigger across the 4px gap below it: @@ -559,8 +572,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff const rowTitle = await sessionRow.locator('[class*="title"]').innerText() // Row menu: hover reveals the actions button; Archive session commits // without a confirmation dialog (non-destructive: log + accounting stay). - await sessionRow.hover() - await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click() + await clickHoverAction(sessionRow, `Session actions for ${rowTitle}`) await page.getByRole('menuitem', { name: 'Archive session' }).click() // The row disappears on the archive-set echo; with no other visible // stray, the whole Ungrouped bucket withdraws. diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index a9af47fb9b..d13f07ffa4 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, then run:\n\n```sh\npnpm dsh ...\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 run \"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, 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 run \"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" }, { "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克隆本仓库,然后运行:\n\n```sh\npnpm dsh ...\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 run \"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克隆本仓库,然后运行:\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 run \"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" }, { "role": "user", From 2765de87c888f5148536f52bd0573fb45e46b621 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 19:07:48 +0800 Subject: [PATCH 54/77] fix(ci): await math snapshot metrics --- apps/web/tests/math-rendering.e2e.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index b183fe7df5..de24c1ca76 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -119,6 +119,10 @@ describe('web e2e: settled Markdown math rendering', () => { await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6) await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) expect(await page.locator('.katex-error').count()).toBe(0) + await expect.poll( + () => page.getByText('Input 0 tok · Output 0 tok', { exact: false }).count(), + { timeout: 10_000 }, + ).toBe(1) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') From e59af75554ed50e11eaf08cbf6eeb38350d33ab7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 19:29:13 +0800 Subject: [PATCH 55/77] fix(cli): keep source launch output clean --- ...ce-run-without-managed-installer.i18n.yaml | 4 +- ...10-source-run-without-managed-installer.md | 4 +- ...source-run-without-managed-installer.zh.md | 4 +- apps/cli/tests/source-launch.compat.spec.ts | 16 +++++++ docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 3 +- docs/user/guide/quickstart.zh.md | 3 +- package.json | 2 +- scripts/run-source-dsh.ts | 48 +++++++++++++++++++ 9 files changed, 75 insertions(+), 13 deletions(-) create mode 100644 scripts/run-source-dsh.ts diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml index 90f4d0e145..f922fdaa95 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.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/simplification/2026-08-10-source-run-without-managed-installer.md -2026-08-10-source-run-without-managed-installer.md: 35f0c705731147ce8bb64c2e985789aff341996c -2026-08-10-source-run-without-managed-installer.zh.md: 51b336f4c399ebfd24f499288eee477bed9f23cb +2026-08-10-source-run-without-managed-installer.md: ecc4530b90a495f210476f100e894a7ce77cdb63 +2026-08-10-source-run-without-managed-installer.zh.md: 940bc6245bacc91abea4e2c746d81dbce4a50105 diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md index 35f0c70573..ecc4530b90 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md @@ -12,7 +12,7 @@ That lifecycle is not required to run or develop DeepSeek Harness from a source ## Decision -The repository supports source execution through its root `pnpm` scripts. The `pnpm dsh ` script runs the complete repository build before launching the source CLI; users select Web with `pnpm dsh web` and headless execution with `pnpm dsh run`. The independent ACP example remains available through `pnpm run demo:acp`. +The repository supports source execution through its root `pnpm` scripts. The `pnpm dsh ` launcher runs the complete repository build before launching the source CLI. It discards successful build output so CLI stdout remains machine-readable, reports failed build diagnostics on stderr, and sets `NODE_USE_ENV_PROXY=1` for the CLI process. Users select Web with `pnpm dsh web` and headless execution with `pnpm dsh run`. The independent ACP example remains available through `pnpm run demo:acp`. The repository does not distribute a source installer, an installer test suite, or skills that assume a managed `current` symlink and timestamped staging worktrees. Users own source checkout placement, Git updates, and any launcher they create outside the repository. @@ -28,4 +28,4 @@ The repository does not distribute a source installer, an installer test suite, Source users invoke repository scripts rather than an installed `dsh` command. The repository provides no atomic upgrade cutover or preserved staging rollback checkout, and it does not automate the integration or upstream publication of personal source modifications. A future distribution mechanism must justify its ownership of installation and upgrade state, define recovery behavior, and add tests and user documentation without making the source-run path depend on it. Any future publication workflow must isolate one approved feature and obtain explicit approval before its first push and draft PR. -Verification covers repository-wide references to the removed entry points, documentation links, generated third-party-notice freshness, and a clean-artifact source CLI smoke through `pnpm dsh`. +Verification covers repository-wide references to the removed entry points, documentation links, generated third-party-notice freshness, and source CLI smokes that verify the build-first launch and absence of build logs on stdout through `pnpm dsh`. diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md index 51b336f4c3..940bc6245b 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -仓库通过根目录的 `pnpm` 脚本支持从源码运行。`pnpm dsh ` 脚本会先完成整个仓库的构建,再启动源码 CLI(命令行界面);用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh run` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 +仓库通过根目录的 `pnpm` 脚本支持从源码运行。`pnpm dsh ` 启动器会先完成整个仓库的构建,再启动源码 CLI(命令行界面)。它会丢弃成功构建的输出,使 CLI stdout 保持机器可读;在 stderr 中报告构建失败的诊断信息;并为 CLI 进程设置 `NODE_USE_ENV_PROXY=1`。用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh run` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 仓库不分发源码安装器、安装器测试套件,也不分发依赖受管理的 `current` 符号链接和带时间戳 staging worktree 的 skill。源码检出的存放位置、Git 更新,以及用户在仓库外创建的任何启动器均由用户负责。 @@ -28,4 +28,4 @@ Status: implemented 源码用户通过仓库脚本运行程序,而非使用已安装的 `dsh` 命令。仓库不提供原子升级切换,也不保留 staging 回滚检出;仓库同样不会自动集成个人源码修改或将其发布到上游。未来的分发机制必须说明为何应由其管理安装和升级状态,定义恢复行为,并补充测试与用户文档,同时不得让源码运行路径依赖该机制。未来任何发布工作流都必须隔离出一项获批功能,并在首次推送和创建草稿 PR(Pull Request)前取得明确批准。 -验证范围包括仓库内对已移除入口点的所有引用、文档链接、生成的第三方声明文件的新鲜度,以及在无构建产物状态下通过 `pnpm dsh` 启动源码 CLI 的冒烟测试。 +验证范围包括仓库内对已移除入口点的所有引用、文档链接、生成的第三方声明文件的新鲜度,以及通过 `pnpm dsh` 对源码 CLI 进行冒烟测试,验证先构建后启动且 stdout 不含构建日志。 diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index 5d92d021f0..68b4f73158 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -16,6 +16,22 @@ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshSourceBin = 'apps/cli/src/bin.ts' describe('dsh SOURCE launcher (node --import tsx/esm)', () => { + it('builds without mixing build logs into CLI stdout', async () => { + const result = await execa('pnpm', ['dsh', '--help'], { + cwd: repoRoot, + timeout: 120_000, + killSignal: 'SIGKILL', + reject: false, + }) + if (result.timedOut) { + throw new Error(`pnpm dsh --help did not exit within 120s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + expect(result.exitCode).toBe(0) + expect(result.stdout).toMatch(/^Usage: dsh /) + expect(result.stdout).not.toContain('tsdown') + expect(result.stdout).not.toContain('build:lib') + }, 125_000) + it('boots the source entry and requires a profile', async () => { const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], { cwd: repoRoot, diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 83eea0ed76..f6c6096f40 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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/guide/quickstart.md -quickstart.md: 540eed86bc83e70f3a9724f601fd71170996ce0b -quickstart.zh.md: ab529333eb3c62add8cf422328b0ba838efc82e6 +quickstart.md: f6537c71fc94096b2daf49827d041979346632bb +quickstart.zh.md: fbecabb964e3786929b3b942f49b5b44a4437e05 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 540eed86bc..f6537c71fc 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -19,10 +19,9 @@ pnpm -v ## Step 1: install and configure the API key ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness cd deepseek-harness pnpm install -pnpm run build ``` Create the gitignored repository-root `.env`: diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index ab529333eb..fbecabb964 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -19,10 +19,9 @@ pnpm -v ## 第一步:安装并配置 API 密钥 ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness cd deepseek-harness pnpm install -pnpm run build ``` 在仓库根目录创建已被 Git 忽略的 `.env`: diff --git a/package.json b/package.json index 4acd724112..d34d3322b2 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "release:pack": "tsx scripts/release/pack.ts", "release:verify-packed-install": "tsx scripts/release/verify-packed-install.ts", "release:publish": "tsx scripts/release/publish.ts", - "dsh": "npm run build && node --import tsx/esm apps/cli/src/bin.ts", + "dsh": "tsx scripts/run-source-dsh.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/scripts/run-source-dsh.ts b/scripts/run-source-dsh.ts new file mode 100644 index 0000000000..3d97a44faa --- /dev/null +++ b/scripts/run-source-dsh.ts @@ -0,0 +1,48 @@ +/** + * Source-checkout launcher. Successful build output stays out of CLI stdout; + * build failures report their captured diagnostics on stderr before exiting. + */ +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { execa } from 'execa' + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)) +const sourceBin = fileURLToPath(new URL('../apps/cli/src/bin.ts', import.meta.url)) + +function completeFrom(result: { readonly exitCode?: number; readonly signal?: string }): void { + if (result.signal !== undefined) { + process.kill(process.pid, result.signal) + return + } + process.exitCode = result.exitCode ?? 1 +} + +function reportBuildFailure(result: { + readonly all: string | undefined + readonly shortMessage: string | undefined +}): void { + const diagnostic = result.all === undefined || result.all.length === 0 + ? result.shortMessage ?? 'Source build failed without diagnostics.' + : result.all + process.stderr.write(diagnostic.endsWith('\n') ? diagnostic : `${diagnostic}\n`) +} + +const build = await execa('pnpm', ['run', 'build'], { + all: true, + cwd: repoRoot, + reject: false, + stripFinalNewline: false, +}) +if (build.failed) { + reportBuildFailure(build) + completeFrom(build) +} else { + const cli = await execa(process.execPath, ['--import', 'tsx/esm', sourceBin, ...process.argv.slice(2)], { + cwd: repoRoot, + env: { ...process.env, NODE_USE_ENV_PROXY: '1' }, + reject: false, + stdio: 'inherit', + }) + completeFrom(cli) +} From 01257351d984489706ec9d296e13ad1073d0b018 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 19:32:00 +0800 Subject: [PATCH 56/77] fix(ci): recognize root dsh script --- knip.json | 1 + 1 file changed, 1 insertion(+) diff --git a/knip.json b/knip.json index 2f755dbb5f..af57e24303 100644 --- a/knip.json +++ b/knip.json @@ -5,6 +5,7 @@ ], "ignoreBinaries": [ "bwrap", + "dsh", "icacls", "musl-gcc", "python3", From d8e50c2c0b1e384b210beceb0d80f44885d0833e Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 20:21:06 +0800 Subject: [PATCH 57/77] fix(review): address source-run feedback --- .agents/notes/archived/manifest.json | 6 +++ ...sonal-staging-maintenance-skills.i18n.yaml | 6 +++ ...-23-personal-staging-maintenance-skills.md | 36 +++++++++++++ ...-personal-staging-maintenance-skills.zh.md | 36 +++++++++++++ ...staller-adopts-existing-checkout.i18n.yaml | 6 +++ ...7-31-installer-adopts-existing-checkout.md | 52 +++++++++++++++++++ ...1-installer-adopts-existing-checkout.zh.md | 52 +++++++++++++++++++ README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- lefthook.yml | 8 +++ .../request-response.expected.json | 4 +- 15 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml create mode 100644 .agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.md create mode 100644 .agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.zh.md create mode 100644 .agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml create mode 100644 .agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.md create mode 100644 .agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.zh.md diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 017fd32b27..1a814aac63 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -298,12 +298,18 @@ "process/2026-07-23-browser-demo-gif-recording.i18n.yaml": "sha256:808ccdda39e540645b440e40a2124baed737b98636265d8f6d8cf036a70f0d50", "process/2026-07-23-browser-demo-gif-recording.md": "sha256:4d3a3dc829c75b66f4f57a6a763b4b9562ce10efb90a19308142f598c5ea8524", "process/2026-07-23-browser-demo-gif-recording.zh.md": "sha256:409e5d31ea87f35c5227fcd1bd105167f580a8a24d29a223ce390b29eb31639a", + "process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml": "sha256:cbd5c32f2997713339699f4c12876b1950895fcb21594927c7509e3cb4c5b8d5", + "process/2026-07-23-personal-staging-maintenance-skills.md": "sha256:a1ef960ddc47c8bc14578d8432ed0ac4272e31178937780989e4e3b3e3f4d49f", + "process/2026-07-23-personal-staging-maintenance-skills.zh.md": "sha256:c0f3fcf0914af88cafcc7e094a2131bd1f764003defc6eaa446b743319e73ec1", "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml": "sha256:89b6dc255cb0dd9d97ae6f34f37d185412d4260d7bf2f87d6b25e340f01dc26e", "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md": "sha256:1742e09435ade4a09349c8843eb381e870a2be2d51b74449f08260422c8096b3", "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md": "sha256:220bd53a88617b09ee8970627c46540fa8951724b102acfe296a7ed7d0f7b5fe", "process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5", "process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936", "process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e", + "process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml": "sha256:e7d072856cd3df3b717d94647be4ea05db087b114df4b11d68a171677b94c970", + "process/2026-07-31-installer-adopts-existing-checkout.md": "sha256:d97e39d07bde30d534417a406fc6c56ee6765dbc7b671888d17ef2cac9a7c6dc", + "process/2026-07-31-installer-adopts-existing-checkout.zh.md": "sha256:ab445493c1aacf3f8dc091bdbd765ad56c551ae613e0d8156610f0c4614a7431", "process/2026-08-04-forward-only-pr-issue-status.i18n.yaml": "sha256:af23e203a66a95674154899410e2f420d1d0685dbf856c24cfccdaa547a17925", "process/2026-08-04-forward-only-pr-issue-status.md": "sha256:2d31077da47d95ab3ddf64d5efc6b1b8fb7c7709d39aca4a825ef9e9d382d501", "process/2026-08-04-forward-only-pr-issue-status.zh.md": "sha256:b61f865b7a8a0ac901250a3edbb92ea73177067c4c25448c7088925c2caeccd7", diff --git a/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml new file mode 100644 index 0000000000..ecf2ec52a1 --- /dev/null +++ b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.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/process/2026-07-23-personal-staging-maintenance-skills.md +2026-07-23-personal-staging-maintenance-skills.md: 86654be1afe986107e7dae4440b497a266636012 +2026-07-23-personal-staging-maintenance-skills.zh.md: 7593a2e8cafc93df02c9418c94f84bbd3f154a0c diff --git a/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.md b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.md new file mode 100644 index 0000000000..86654be1af --- /dev/null +++ b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.md @@ -0,0 +1,36 @@ +# Agent Note: Personal staging maintenance skills + +Status: implemented +Archived: 2026-08-10 + +English | [中文](2026-07-23-personal-staging-maintenance-skills.zh.md) + +## Problem + +Personal dsh customizations need a repeatable way to locate the installed source, isolate task work, serialize integration, and incorporate upstream changes without rewriting the checkout used by running sessions. User-local instructions solve this for one installation but cannot guide other users or remain synchronized with repository installer behavior. + +## Decision + +The repository distributes [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md), [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md), and [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md) from its root `skills/` directory. Their descriptions name both the operation and user requests that select it. The shipped TUI supplies that directory to the local skill provider at startup, below project and user roots in discovery priority. The workflows derive the active checkout and staging branch from the installed launcher rather than a user-specific path or branch name, defer to repository-local instructions, require task worktrees, and serialize staging mutations with the staging worktree's established `.agents/merge.lock`. + +Before rebasing, an upgrade inspects the Git log and commit ranges to identify incoming upstream changes, personal commits, duplicates, and likely conflicts. It drops customizations already supplied upstream; when only a documentary local diff remains for such a customization, it also drops that account unless it adds an independently useful current contract absent upstream. Each attempt uses one UTC basic timestamp for its independent `dsh-staging-` sibling clone, local `dsh-upgrade/prepare-` branch, new `dsh-staging/` branch, private upstream and recovery refs, and launcher backup. The sibling name does not derive from the current directory name, and collisions fail rather than acquiring ad hoc suffixes. The workflow derives the current DSH process source from the process command and runtime environment rather than the shell working directory, then treats the repository and checkout behind the installed launcher as immutable except for holding its existing merge lock. + +After validation in the independent clone, the workflow creates and verifies the timestamped staging branch, then atomically moves the launcher once from the unchanged old staging checkout to the new staging checkout. The launcher never targets a preparation, feature, review, publication, or detached checkout. Failure before cutover leaves the installed checkout and launcher unchanged; failure after cutover restores and verifies the launcher backup. The old staging checkout, its branch, the recovery ref, and the launcher backup remain available until a restarted process proves that DSH runs from the new staging branch and the user explicitly approves rollback cleanup. + +`dsh-upstream-customization` owns upstream publication independently from local maintenance and upgrades. It recommends bug fixes, additive non-conflicting plugin features, and visual improvements; intrusive changes require maintainer approval first. At the end of an upgrade, the agent classifies remaining customizations, explains their upstream value, recommends whether to propose each one, and asks which named candidate the user wants to upstream. Only that selection loads the publication workflow; each feature still requires explicit approval before a push or draft PR. Approved changes start from current upstream `master` without unrelated personal commits. Draft PRs for TUI features preferably include a screenshot from the assembled application after credentials and personal data are removed. `dsh-customize` requires interactive TUI behavior to be exercised in a dedicated tmux session before integration. + +## Alternatives considered + +**Keep the workflows user-scoped.** This preserves personal flexibility but prevents other users from discovering the same safety rules and lets the workflow drift from the installer shipped by the repository. + +**Rebase the active staging checkout in place.** This is simpler but changes many files during preparation, can disrupt new dsh launches, and cannot provide atomic publication or an unchanged rollback checkout. + +**Update the existing staging checkout after moving the launcher elsewhere.** This retains one staging path but requires a mid-upgrade launcher target that is not a staging branch and still rewrites a checkout that may host a running process. + +**Lock only the final branch switch.** This shortens lock duration but permits a customization merge against the old base while the rebase is being prepared, invalidating the prepared history. + +**Open one upstream PR for all personal changes.** This reduces branch management but publishes unrelated customizations and removes the user's per-feature approval boundary. + +## Consequences + +Upgrade preparation holds the installed staging merge lock while dependencies and checks run, so local customization integration waits for a consistent result. One upgrade creates an independent timestamped clone and staging branch, performs one atomic launcher cutover, and requires one restart afterward; it never writes into the repository or checkout behind the launcher except to hold its existing lock. Each workflow records preconditions, repeats them before mutation, inspects state after interrupted mutations, restores the launcher backup on cutover failure, reruns failed checks after correction, and reports final state. The old staging checkout remains rollback storage until explicit user-approved cleanup. Checked-in evaluations cover selection, process-source protection, unsafe repository states, rollback, and publication authorization; repository documentation checks validate skill links and formatting, while technical review remains responsible for Git and filesystem correctness. diff --git a/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.zh.md b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.zh.md new file mode 100644 index 0000000000..7593a2e8ca --- /dev/null +++ b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 个人集成分支维护 skill + +Status: implemented +Archived: 2026-08-10 + +[English](2026-07-23-personal-staging-maintenance-skills.md) | 中文 + +## 问题 + +个人 dsh 定制需要一套可重复执行的方法,用于定位已安装的源码、隔离各项任务的修改、串行集成变更,并在不改写运行中会话所用检出的前提下合入上游变更。用户本地指令能解决某一套安装中的问题,却无法指导其他用户,也无法持续与仓库安装脚本的行为保持同步。 + +## 决策 + +仓库从其根 `skills/` 目录分发 [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md)、[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md) 和 [`dsh-upstream-customization`](../../../../skills/dsh-upstream-customization/SKILL.md)。它们的描述同时说明操作内容和选择该 skill(技能)的用户请求。分发的 TUI 在启动时将该目录提供给本地 skill 提供方,在发现优先级上位于项目根目录和用户根目录之后。这些 skill 根据已安装的启动器而非个人路径或分支名称定位当前生效的检出和集成分支,遵从仓库内指令,要求使用任务 worktree,并利用集成分支所在 worktree 的既有 `.agents/merge.lock`,串行执行每一次个人集成分支修改。 + +升级流程在变基前检查 Git 日志和提交范围,以识别将进入升级的上游变更、个人提交、重复内容和可能发生冲突的区域。它会丢弃上游已经提供的定制;如果这类定制在本地只剩文档差异,也会一并丢弃,除非该说明包含上游缺失且可独立使用的当前约定。每次升级尝试使用同一个 UTC 基本格式时间戳,用于其独立的 `dsh-staging-` 同级克隆、本地 `dsh-upgrade/prepare-` 分支、新的 `dsh-staging/` 分支、私有的上游引用与恢复引用,以及启动器备份。同级克隆的名称不派生自当前目录名,名称冲突会直接失败,而不是追加临时后缀。流程根据进程命令和运行时环境而非 shell 工作目录推导当前 DSH 进程的源码位置,随后将已安装启动器所指向的仓库和检出视为不可变,唯一例外是持有其既有合并锁。 + +在独立克隆中验证通过后,工作流会创建并验证带时间戳的集成分支,然后以原子方式将启动器从保持不变的旧集成分支检出一次性切换到新集成分支检出。启动器绝不会指向准备、功能、评审、发布或处于分离状态的检出。切换前的失败会让已安装的检出和启动器保持不变;切换后的失败则恢复并验证启动器备份。旧的集成分支检出、其分支、恢复引用和启动器备份会一直保留,直到重启后的进程证明 DSH 运行于新的集成分支,且用户明确批准回滚清理为止。 + +`dsh-upstream-customization` 独立于本地维护和升级,负责向上游发布。它推荐 bug 修复、附加式且不冲突的插件功能,以及视觉改进;侵入式变更需先取得维护者批准。在升级结束时,agent 会对剩余定制进行分类、说明其上游价值、建议是否提交,并询问用户希望向上游贡献哪个具名候选项。只有用户做出选择后才会加载发布工作流;每项功能在推送或创建草稿 PR(Pull Request)前仍必须得到明确批准。获批的变更均以当前上游 `master` 为起点,不带入无关的个人提交。TUI 功能的草稿 PR 建议在移除凭证与个人数据后,附上组装后应用的截图。`dsh-customize` 要求在集成前于专用 tmux 会话中检验交互式 TUI 行为。 + +## 备选方案 + +**将这些工作流限定在用户本地。** 这样可以保留个人使用的灵活性,但其他用户无法发现同一套安全规则,工作流也可能逐渐偏离仓库分发的安装脚本行为。 + +**在当前集成分支检出中原地变基。** 此方案更简单,但准备期间会修改大量文件,可能干扰新的 dsh 启动,也无法实现原子发布或提供一份保持不变的回滚检出。 + +**在将启动器迁往别处后更新现有的集成分支检出。** 此方案可以保留单一的集成分支路径,却要求在升级中途让启动器指向一个并非集成分支的目标,且仍会改写可能承载运行中进程的检出。 + +**只在最终切换分支时加锁。** 这样可以缩短持锁时间,却允许写入方在变基准备期间继续基于旧基线合并定制变更,导致准备好的历史失效。 + +**用一个上游 PR 发布所有个人变更。** 这会减少分支管理工作,却会发布无关的定制,并取消用户按功能逐项批准的边界。 + +## 影响 + +升级准备流程在安装依赖和运行检查期间持有已安装集成分支的合并锁,因此本地定制的集成必须等待一致的结果。一次升级会创建独立的带时间戳的克隆和集成分支,执行一次原子的启动器切换,并在切换后要求重启一次;除持有其既有锁之外,升级绝不会写入启动器所指向的仓库或检出。各工作流会记录前置条件、在修改前重复检查、在修改被中断后检查状态、在切换失败时恢复启动器备份、修复后重新运行失败的检查,并报告最终状态。旧的集成分支检出会作为回滚存储一直保留,直到用户明确批准清理为止。仓库内评估覆盖 skill 选择、进程源码保护、不安全的仓库状态、回滚和发布授权;仓库文档检查会验证 skill 的链接和格式,Git 与文件系统操作的正确性仍由技术评审负责。 diff --git a/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml new file mode 100644 index 0000000000..c71f556ed2 --- /dev/null +++ b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.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/process/2026-07-31-installer-adopts-existing-checkout.md +2026-07-31-installer-adopts-existing-checkout.md: 08eec47a1cf1254409df93ab83ddf503b4d7d171 +2026-07-31-installer-adopts-existing-checkout.zh.md: dc3cc5ee8dbbe287860ad02f4bdba66afb4fbeaf diff --git a/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.md new file mode 100644 index 0000000000..08eec47a1c --- /dev/null +++ b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.md @@ -0,0 +1,52 @@ +# Agent Note: the installer adopts an existing checkout into the managed layout + +Status: implemented +Archived: 2026-08-10 + +English | [中文](2026-07-31-installer-adopts-existing-checkout.zh.md) + +## Problem + +`scripts/install.sh` produced two incompatible installation layouts. A `curl … | sh` install built the managed layout — a master clone at `~/.dsh/source/master`, a staging worktree on `dsh-staging/`, and the stable `current` symlink the PATH launcher resolves through. Running the same script from a checkout instead linked `dsh` straight at that checkout's `bin/dsh`, per the earlier [in-repo skip-clone decision](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md). + +The direct link cannot be upgraded. `current` is what an upgrade repoints, so an install without it is not upgradable by [`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md); the PATH symlink dangles if the checkout moves; and the launcher resolves to whatever branch the contributor happened to have checked out, which the upgrade contract forbids as a launcher target. The upgrade skill already described this layout as a legacy install needing a one-time migration, so the layouts diverged at install time and were reconciled only later, if ever. + +## Decision + +In-repo mode still never clones and never modifies the working tree, but it now **adopts** the checkout into the managed layout unconditionally. There is no opt-out: one layout serves every install. + +The container owns staging worktrees and `current`; the repository is *discovered*, not owned. `git rev-parse --git-common-dir` resolves the shared git directory behind the checkout — for a linked worktree that is the real clone rather than the worktree itself — and its parent is the repository that serves as the upgrade base. A staging worktree branched from the checkout's `HEAD` is then created under `$DSH_SOURCE`, and `current` points at it. A clone anywhere on disk therefore converges on the same layout as a `curl` install, and the two paths share one worktree/exclude/lock/link sequence: they differ only in whether the repository was discovered by `git clone` or by `git rev-parse`. + +The installer records nothing about where that repository lives. A container whose repository sits outside it is not self-contained — each staging worktree holds an absolute gitdir pointer into that clone, so deleting the clone breaks them — but git already owns that fact: the worktree's `.git` file names the path, and `git worktree list` in the clone enumerates every worktree depending on it. + +Adoption branches from `HEAD`, so committed work is what runs and uncommitted changes stay in the checkout. This is not prompted or warned about: the installer builds the layout and gets out of the way. Setting `DSH_SOURCE` to a different directory remains the one documented way to opt back into cloning a separate tree. + +Every path comparison runs on physical paths through a `resolve_dir` helper, and every compared value is resolved at assignment rather than at the comparison. Git always reports resolved paths, so comparing one against an unresolved path disagrees whenever a symlink sits anywhere above the checkout — a symlinked home directory is enough, and macOS reaches every `mktemp` path that way through `/var` -> `private/var`. The mismatch misclassified an existing managed install as a foreign clone and would have built a second container beside the real one. The same defect class recurs whenever one side of a comparison is left unresolved — as it did with a curl install's `REPO_ROOT` and with the container path it was compared against. `resolve_dir` therefore echoes a missing path back rather than failing, so a not-yet-created container needs no per-call fallback and no site can compare against an empty path by forgetting one; callers that need "does not exist" test the directory explicitly. `git rev-parse --path-format=absolute` would do the same job but requires git 2.31+. + +Before `current` is repointed, the installer rejects a staging path that resolves to the repository itself, enforcing the upgrade contract that the launcher never resolves to the master clone. + +## Alternatives considered + +**Make `~/.dsh/source/master` a symlink to the arbitrary clone.** Rejected. Git resolves the symlink and records the *real* path: a worktree created through it stores `gitdir: …//.git/worktrees/`, and `git worktree list` reports the clone. The symlink is therefore decorative — nothing reads it — while implying the container owns the repository. It also fails silently: moving the clone leaves `master` present but dangling and every staging worktree dead with `fatal: not a git repository`. Worst, it aliases two names onto one tree, so the "current must never be the master clone" check passes by string comparison while being false. `~/.dsh/source/master` is a location, not a name, and only the location is authoritative. + +**Promote the checkout itself to the `current` target.** Rejected: the upgrade contract requires `current` to point to a clean staging worktree on a staging branch, never a feature, review, or detached checkout. It would also make every upgrade rewrite the tree the contributor is editing. + +**Keep link-in-place behind a prompt or a `DSH_ADOPT` flag.** Rejected, and an earlier revision of this change shipped exactly that before it was removed. The second layout was the defect itself, so retaining it as an option preserves the problem and doubles the states every later change must handle — the prompt, the flag, the dirty-tree warning, and a second linking path all existed only to keep a layout nothing should produce. The original motivation for link-in-place, keeping the script testable against local source, survives adoption: a staging worktree branched from the checkout's `HEAD` runs the same code. `DSH_SOURCE` remains available for installing a separate tree. + +**Warn or prompt when the tree is dirty.** Rejected: `worktree add` from `HEAD` cannot carry uncommitted work, so the behavior is determined and a prompt only adds a decision the user cannot act on differently. The contract is documented instead. + +**Put an adopted clone's staging worktrees beside the clone** (`~/src/staging-*`) rather than in `~/.dsh/source`. Rejected: `current` and the PATH launcher are per-user singletons, so scattering worktrees across clone parents reintroduces the sibling-clone sprawl the source container exists to prevent. + +## Consequences + +One layout now serves every install, so an adopted clone is upgradable by `dsh-upgrade` without the one-time migration that skill described, and the installer has no branch that produces an unupgradable layout. In-repo runs still never mutate the working tree. + +The cost is that a contributor can no longer point PATH at a checkout and have `dsh` follow that working tree as they switch branches: the launcher now resolves to a staging worktree pinned to the `HEAD` adopted at install time. Re-running the installer adopts the current `HEAD` again. + +A container adopting an outside clone is also no longer self-contained: deleting that clone breaks its staging worktrees. This is inherent to reusing an existing clone rather than a property of this design — the rejected symlink hides it rather than fixing it — and git's own worktree records are what diagnose it. + +## Testing + +`scripts/install.sh` now has a real-shell PTY regression suite in `apps/cli/tests/install-script.spec.ts`, covering adoption and curl-style paths with stubbed dependencies. Curl-style installs default to the public `deepseek-ai/deepseek-harness-sdk` source, while replacing the installer with pnpm/npx remains separate work. + +Verification was manual, through a throwaway harness driving the real script with a stubbed `pnpm`: adopting a standalone clone; adopting from a linked worktree into its existing container; an explicit `DSH_SOURCE` still opting back into cloning; a dirty tree adopting silently with no prompt or warning while its uncommitted file stays behind; a non-git checkout failing with guidance; and a `curl`-style clone install asserting the built layout, the check that catches an unresolved `REPO_ROOT`. The interactive path was exercised under tmux from a dirty checkout, confirming the run reaches the launcher with no adoption prompt and ends with `dsh` running from the new staging worktree while the original checkout keeps its branch and its uncommitted file. diff --git a/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.zh.md new file mode 100644 index 0000000000..dc3cc5ee8d --- /dev/null +++ b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 安装器把已有检出接管进受管布局 + +Status: implemented +Archived: 2026-08-10 + +[English](2026-07-31-installer-adopts-existing-checkout.md) | 中文 + +## Problem + +`scripts/install.sh`会产生两种互不兼容的安装布局。`curl … | sh`安装会构建受管布局——`~/.dsh/source/master`处的 master 克隆、位于`dsh-staging/<时间戳>`分支上的 staging worktree,以及 PATH 启动器据以解析的稳定`current`符号链接。而从检出中运行同一脚本时,则依据此前的[检出内跳过克隆决策](../../archived/process/2026-07-22-installer-in-repo-skip-clone.md),把`dsh`直接链接到该检出的`bin/dsh`。 + +这种直接链接无法升级。升级重指的正是`current`,因此缺少它的安装无法通过[`dsh-upgrade`](../../../../skills/dsh-upgrade/SKILL.md)升级;检出一旦移动,PATH 符号链接就会失效;而且启动器会解析到贡献者恰好检出的任意分支,这正是升级约定禁止作为启动器目标的情形。升级技能早已把这种布局描述为需要一次性迁移的旧式安装,因此两种布局从安装时起便不相同,只有以后执行迁移才会一致,而迁移也可能永远不执行。 + +## Decision + +检出内模式仍然绝不克隆、绝不修改工作树,但现在它会无条件地把该检出**接管**进受管布局。不存在退出选项:一套布局服务于所有安装。 + +容器拥有 staging worktree 和`current`;仓库是被*发现*的,而非被拥有的。`git rev-parse --git-common-dir`会解析出该检出背后的共享 git 目录——对于 linked worktree,那是真正的克隆而非 worktree 自身——其父目录即是充当升级基础的仓库。随后以该检出的`HEAD`为起点,在`$DSH_SOURCE`下创建 staging worktree,并让`current`指向它。因此,磁盘上任意位置的克隆都会收敛到与`curl`安装相同的布局,且两条路径共用同一套 worktree/exclude/lock/link 流程:二者的唯一差别,只在于仓库是由`git clone`发现的,还是由`git rev-parse`发现的。 + +安装器不会记录该仓库位于何处。仓库位于容器之外时,容器就不是自包含的——每个 staging worktree 都持有指向该克隆的绝对 gitdir 指针,删除该克隆就会破坏它们——但这一事实本就由 git 自己掌握:worktree 的`.git`文件写明了该路径,而在该克隆中执行`git worktree list`会列出依赖于它的每一个 worktree。 + +接管以`HEAD`为分支起点,因此运行的是已提交的内容,未提交的更改仍留在检出中。这一点既不提示也不警告:安装器构建好布局后便不再打扰。把`DSH_SOURCE`设为其他目录,仍是唯一有文档记载的、回到克隆另一棵树的方式。 + +所有路径比较都通过`resolve_dir`辅助函数在物理路径上进行,且每个参与比较的值都在赋值时解析,而非在比较时解析。git 报告的始终是已解析的路径,因此只要检出之上任意一层存在符号链接,拿它与未解析的路径相比较就会不相等——家目录本身是符号链接即已足够,而 macOS 通过`/var` -> `private/var`使每个`mktemp`路径都如此。这种不匹配会把已有的受管安装误判为外来克隆,并在真正的容器旁再建一个容器。只要比较的一侧未经解析,同类缺陷就会重现——curl 安装的`REPO_ROOT`以及与之比较的容器路径都曾如此。因此`resolve_dir`在路径不存在时原样回显该路径而非失败,这样尚未创建的容器无需在每个调用点单独兜底,也就没有调用点会因遗漏兜底而与空路径比较;需要判断"不存在"的调用方则显式检测该目录。`git rev-parse --path-format=absolute`能完成同样的工作,但要求 git 2.31 及以上版本。 + +在重指`current`之前,安装器会拒绝解析结果等于仓库自身的 staging 路径,以此落实"启动器绝不解析到 master 克隆"这一升级约定。 + +## Alternatives considered + +**把`~/.dsh/source/master`做成指向该任意克隆的符号链接。** 已否决。Git 会解析该符号链接并记录*真实*路径:经由它创建的 worktree 会存储`gitdir: …/<克隆>/.git/worktrees/<名称>`,而`git worktree list`报告的是该克隆。因此这个符号链接纯属装饰——没有任何代码读取它——却又暗示容器拥有该仓库。它还会静默失效:移动克隆后,`master`看似仍在却已悬空,而每个 staging worktree 都会以`fatal: not a git repository`失败。最糟的是,它把两个名称别名到同一棵树上,于是"current 绝不能是 master 克隆"这项检查会在字符串比较下通过,实则为假。`~/.dsh/source/master`是位置而非名称,且只有位置具有权威性。 + +**把检出自身提升为`current`的目标。** 已否决:升级约定要求`current`指向 staging 分支上的干净 staging worktree,绝不能指向 feature、review 或 detached 检出。这还会使每次升级都改写贡献者正在编辑的那棵树。 + +**把就地链接保留在提示或`DSH_ADOPT`开关之后。** 已否决;本次变更的早期修订版本正是如此实现,之后被移除。第二种布局本身就是缺陷,因此把它保留为一个选项等于保留了问题,并使此后每次改动必须处理的状态翻倍——提示、开关、工作树不干净的警告,以及第二条链接路径,全都只为维持一种本不该产生的布局而存在。就地链接最初的动机——让脚本能针对本地源码进行测试——在接管方案下依然成立:以检出的`HEAD`为起点创建的 staging worktree 运行的是同一份代码。`DSH_SOURCE`仍可用于安装另一棵树。 + +**在工作树不干净时发出警告或提示。** 已否决:以`HEAD`为起点的`worktree add`本就无法带上未提交的内容,因此该行为是确定的,提示只会增加一个用户无法做出不同选择的决策点。改为在文档中说明该约定。 + +**把被接管克隆的 staging worktree 放在该克隆旁边**(`~/src/staging-*`),而非放进`~/.dsh/source`。已否决:`current`和 PATH 启动器都是每用户唯一的,因此把 worktree 散落到各个克隆的父目录中,会重新引入 source 容器本就为之而设、意在杜绝的同级克隆蔓延问题。 + +## Consequences + +现在一套布局服务于所有安装,因此被接管的克隆无需该技能所述的一次性迁移,即可由`dsh-upgrade`升级,而且安装器不再有任何一条分支会产生无法升级的布局。检出内运行仍然绝不改动工作树。 + +代价是:贡献者不能再把 PATH 指向某个检出、并让`dsh`随其切换分支而跟随该工作树;启动器现在解析到的是一个固定在安装时所接管`HEAD`上的 staging worktree。重新运行安装器会再次接管当前的`HEAD`。 + +此外,接管外部克隆的容器不再自包含:删除该克隆会破坏其 staging worktree。这是复用已有克隆的固有属性,而非本设计带来的性质——被否决的符号链接方案只是掩盖它,而非修复它——诊断依据则是 git 自身的 worktree 记录。 + +## Testing + +`scripts/install.sh` 现有一套位于 `apps/cli/tests/install-script.spec.ts` 的真实 shell PTY 回归测试,使用 stub 依赖覆盖接管路径和 curl 风格路径。curl 风格安装默认使用公开的 `deepseek-ai/deepseek-harness-sdk` 源,而以 pnpm/npx 替换安装器仍是另一项工作。 + +验证是手工完成的,通过一个一次性测试装置以打桩的`pnpm`驱动真实脚本:接管独立克隆;从 linked worktree 接管进其已有容器;显式`DSH_SOURCE`仍回到克隆路径;工作树不干净时静默接管、既不提示也不警告,且其未提交文件留在原处;非 git 检出失败并给出指引;以及`curl`式克隆安装断言所构建的布局——该检查能捕获未解析的`REPO_ROOT`。交互路径在 tmux 下从一个不干净的检出走通,确认整个过程不出现接管提示即可到达启动器,最终`dsh`从新的 staging worktree 运行,而原检出保持其分支不变、未提交文件仍在。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 12b9377e4a..e61b02e20a 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: 98091a2b4468bebe6e571d66c06c8e343b1595ea -README.zh.md: 3842ac35c4fbd5c51833cb20e8e079cbbb641e7a +README.md: 3c68363198010d9b1f661aad9e8fb6766d10888e +README.zh.md: 2ac72a5d54c677b4f0ac3d51685929d07701f782 diff --git a/README.md b/README.md index 98091a2b44..3c68363198 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The internal build uploads all Session Logs by default to help diagnose reported ## Run from source -Clone this repo, then run: +Clone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run: ```sh pnpm dsh web diff --git a/README.zh.md b/README.zh.md index 3842ac35c4..2ac72a5d54 100644 --- a/README.zh.md +++ b/README.zh.md @@ -14,7 +14,7 @@ DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化 ## 从源码运行 -克隆本仓库,然后运行: +克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行: ```sh pnpm dsh web diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 9bef07acad..0393399941 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: e4cab19a79416d6957a3bf5b6e855ee60473b1a0 -README.zh.md: cd58506b9936b3fb6cf6f8063e1bc2768262b971 +README.md: 48c8e8fd902db4de5ceb5532a67d1559453392c4 +README.zh.md: d51b4256b421ecfb71d294c5b3c25bfcf6ed2c8a diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index e4cab19a79..48c8e8fd90 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -81,4 +81,4 @@ Install external plugin bundles through `dsh plugin --profile add `. The script runs the complete repository build, launches `apps/cli/src/bin.ts` with `node --import tsx/esm`, and forwards every argument. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. +From the repository root, use `pnpm dsh `. The script runs the complete repository build, launches `apps/cli/src/bin.ts` with `node --import tsx/esm`, and forwards every argument. Successful build output stays out of CLI stdout, while build failures report diagnostics on stderr. The CLI process receives `NODE_USE_ENV_PROXY=1` so Node versions with environment-proxy fetch support honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index cd58506b99..d51b4256b4 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -81,4 +81,4 @@ dsh web --help ## 源码执行 -请从仓库根目录使用 `pnpm dsh `。该脚本会完成整个仓库的构建,通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 +请从仓库根目录使用 `pnpm dsh `。该脚本会完成整个仓库的构建,通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。构建成功时,其输出不会进入 CLI stdout;构建失败时,诊断信息会写入 stderr。CLI 进程会接收 `NODE_USE_ENV_PROXY=1`,从而让支持环境代理的 Node 版本中的 fetch 遵循 `HTTP_PROXY` 和 `HTTPS_PROXY`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 diff --git a/lefthook.yml b/lefthook.yml index 7ed1fcb886..1c2a693c88 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -10,6 +10,10 @@ pre-commit: - '.agents/notes/archived/**' run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} + - name: archived agent notes + glob: '.agents/notes/archived/**' + run: node_modules/.bin/tsx scripts/verify-archived-agent-notes.ts + - name: lint (staged) glob: '*.{ts,tsx,mts,cts,mjs}' exclude: @@ -41,6 +45,10 @@ pre-merge-commit: - '.agents/notes/archived/**' run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} + - name: archived agent notes + glob: '.agents/notes/archived/**' + run: node_modules/.bin/tsx scripts/verify-archived-agent-notes.ts + pre-push: jobs: - name: typecheck diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index d13f07ffa4..720632f490 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, 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 run \"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 run \"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" }, { "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克隆本仓库,然后运行:\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 run \"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 run \"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" }, { "role": "user", From 9d79da0c62421e4e58646ed9e9b31409596913f3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 20:26:36 +0800 Subject: [PATCH 58/77] test(cli): pin source build-first launch --- apps/cli/tests/source-launch.compat.spec.ts | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index 68b4f73158..e32b217f98 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -1,3 +1,6 @@ +import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { delimiter, join } from 'node:path' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { execa } from 'execa' import { describe, expect, it } from 'vitest' @@ -16,6 +19,34 @@ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshSourceBin = 'apps/cli/src/bin.ts' describe('dsh SOURCE launcher (node --import tsx/esm)', () => { + it('runs the repository build before launching the source CLI', async () => { + const stubDir = await mkdtemp(join(tmpdir(), 'dsh-source-launch-')) + const invocationLog = join(stubDir, 'pnpm-args.json') + const stubBody = "const { appendFileSync } = require('node:fs')\nappendFileSync(process.env.DSH_TEST_PNPM_LOG, JSON.stringify(process.argv.slice(2)))\n" + await writeFile(join(stubDir, 'pnpm'), `#!/usr/bin/env node\n${stubBody}`) + await chmod(join(stubDir, 'pnpm'), 0o755) + await writeFile(join(stubDir, 'pnpm-stub.cjs'), stubBody) + await writeFile(join(stubDir, 'pnpm.cmd'), '@echo off\r\nnode "%~dp0pnpm-stub.cjs" %*\r\n') + + const pathKey = Object.keys(process.env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH' + try { + const result = await execa(process.execPath, ['--import', 'tsx/esm', 'scripts/run-source-dsh.ts', '--help'], { + cwd: repoRoot, + env: { + ...process.env, + [pathKey]: `${stubDir}${delimiter}${process.env[pathKey] ?? ''}`, + DSH_TEST_PNPM_LOG: invocationLog, + }, + reject: false, + }) + expect(result.exitCode).toBe(0) + expect(result.stdout).toMatch(/^Usage: dsh /) + await expect(readFile(invocationLog, 'utf8')).resolves.toBe('["run","build"]') + } finally { + await rm(stubDir, { recursive: true, force: true }) + } + }) + it('builds without mixing build logs into CLI stdout', async () => { const result = await execa('pnpm', ['dsh', '--help'], { cwd: repoRoot, From fc2adf8c6b8e06a2562c8c7b4aa368e70280ea3c Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 20:41:19 +0800 Subject: [PATCH 59/77] fix(ci): isolate source launcher build smoke --- apps/cli/tests/source-launch.compat.spec.ts | 94 ++++++++++++++------- 1 file changed, 65 insertions(+), 29 deletions(-) diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index e32b217f98..748ef62c37 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { delimiter, join } from 'node:path' import { tmpdir } from 'node:os' @@ -18,50 +19,85 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshSourceBin = 'apps/cli/src/bin.ts' +function resolvePnpmExecutable(): string { + const pathKey = Object.keys(process.env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH' + const executableNames = process.platform === 'win32' ? ['pnpm.cmd', 'pnpm.exe', 'pnpm'] : ['pnpm'] + for (const directory of (process.env[pathKey] ?? '').split(delimiter)) { + for (const executableName of executableNames) { + const candidate = join(directory, executableName) + if (existsSync(candidate)) return candidate + } + } + throw new Error('pnpm executable is absent from PATH') +} + +async function createPnpmStub(emitBuildOutput = false): Promise<{ + readonly directory: string + readonly env: NodeJS.ProcessEnv + readonly invocationLog: string +}> { + const directory = await mkdtemp(join(tmpdir(), 'dsh-source-launch-')) + const invocationLog = join(directory, 'pnpm-args.json') + const simulatedOutput = emitBuildOutput + ? "process.stdout.write('tsdown simulated build\\n')\nprocess.stderr.write('build:lib simulated build\\n')\n" + : '' + const stubBody = `const { appendFileSync } = require('node:fs')\nappendFileSync(process.env.DSH_TEST_PNPM_LOG, JSON.stringify(process.argv.slice(2)))\n${simulatedOutput}` + await writeFile(join(directory, 'pnpm'), `#!/usr/bin/env node\n${stubBody}`) + await chmod(join(directory, 'pnpm'), 0o755) + await writeFile(join(directory, 'pnpm-stub.cjs'), stubBody) + await writeFile(join(directory, 'pnpm.cmd'), '@echo off\r\nnode "%~dp0pnpm-stub.cjs" %*\r\n') + const pathKey = Object.keys(process.env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH' + return { + directory, + invocationLog, + env: { + ...process.env, + [pathKey]: `${directory}${delimiter}${process.env[pathKey] ?? ''}`, + DSH_TEST_PNPM_LOG: invocationLog, + }, + } +} + describe('dsh SOURCE launcher (node --import tsx/esm)', () => { it('runs the repository build before launching the source CLI', async () => { - const stubDir = await mkdtemp(join(tmpdir(), 'dsh-source-launch-')) - const invocationLog = join(stubDir, 'pnpm-args.json') - const stubBody = "const { appendFileSync } = require('node:fs')\nappendFileSync(process.env.DSH_TEST_PNPM_LOG, JSON.stringify(process.argv.slice(2)))\n" - await writeFile(join(stubDir, 'pnpm'), `#!/usr/bin/env node\n${stubBody}`) - await chmod(join(stubDir, 'pnpm'), 0o755) - await writeFile(join(stubDir, 'pnpm-stub.cjs'), stubBody) - await writeFile(join(stubDir, 'pnpm.cmd'), '@echo off\r\nnode "%~dp0pnpm-stub.cjs" %*\r\n') - - const pathKey = Object.keys(process.env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH' + const stub = await createPnpmStub() try { const result = await execa(process.execPath, ['--import', 'tsx/esm', 'scripts/run-source-dsh.ts', '--help'], { cwd: repoRoot, - env: { - ...process.env, - [pathKey]: `${stubDir}${delimiter}${process.env[pathKey] ?? ''}`, - DSH_TEST_PNPM_LOG: invocationLog, - }, + env: stub.env, reject: false, }) expect(result.exitCode).toBe(0) expect(result.stdout).toMatch(/^Usage: dsh /) - await expect(readFile(invocationLog, 'utf8')).resolves.toBe('["run","build"]') + await expect(readFile(stub.invocationLog, 'utf8')).resolves.toBe('["run","build"]') } finally { - await rm(stubDir, { recursive: true, force: true }) + await rm(stub.directory, { recursive: true, force: true }) } }) it('builds without mixing build logs into CLI stdout', async () => { - const result = await execa('pnpm', ['dsh', '--help'], { - cwd: repoRoot, - timeout: 120_000, - killSignal: 'SIGKILL', - reject: false, - }) - if (result.timedOut) { - throw new Error(`pnpm dsh --help did not exit within 120s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + const pnpmExecutable = resolvePnpmExecutable() + const stub = await createPnpmStub(true) + try { + const result = await execa(pnpmExecutable, ['dsh', '--help'], { + cwd: repoRoot, + env: stub.env, + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, + }) + if (result.timedOut) { + throw new Error(`pnpm dsh --help did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + expect(result.exitCode).toBe(0) + expect(result.stdout).toMatch(/^Usage: dsh /) + expect(result.stdout).not.toContain('tsdown') + expect(result.stdout).not.toContain('build:lib') + await expect(readFile(stub.invocationLog, 'utf8')).resolves.toBe('["run","build"]') + } finally { + await rm(stub.directory, { recursive: true, force: true }) } - expect(result.exitCode).toBe(0) - expect(result.stdout).toMatch(/^Usage: dsh /) - expect(result.stdout).not.toContain('tsdown') - expect(result.stdout).not.toContain('build:lib') - }, 125_000) + }, 30_000) it('boots the source entry and requires a profile', async () => { const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], { From 3e7cd31ab3c699ed061fbd1a7ff7a71c48fbe642 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 20:44:56 +0800 Subject: [PATCH 60/77] fix(ci): retire root dsh Knip exemption --- knip.json | 1 - 1 file changed, 1 deletion(-) diff --git a/knip.json b/knip.json index af57e24303..2f755dbb5f 100644 --- a/knip.json +++ b/knip.json @@ -5,7 +5,6 @@ ], "ignoreBinaries": [ "bwrap", - "dsh", "icacls", "musl-gcc", "python3", From c3cb4b350eeeeaf20419d209bb34937caf104c12 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 10:27:39 +0800 Subject: [PATCH 61/77] fix(docs): align source examples with headless profile --- ...6-08-10-source-run-without-managed-installer.i18n.yaml | 4 ++-- .../2026-08-10-source-run-without-managed-installer.md | 2 +- .../2026-08-10-source-run-without-managed-installer.zh.md | 2 +- AGENTS.md | 2 +- README.i18n.yaml | 4 ++-- README.md | 2 +- README.zh.md | 2 +- docs/development.i18n.yaml | 4 ++-- docs/development.md | 2 +- docs/development.zh.md | 2 +- packages/support/llm-mock-server/README.i18n.yaml | 4 ++-- packages/support/llm-mock-server/README.md | 2 +- packages/support/llm-mock-server/README.zh.md | 2 +- .../translation-prompt-v4/request-response.expected.json | 8 ++++---- 14 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml index f922fdaa95..0a1cac0e80 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.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/simplification/2026-08-10-source-run-without-managed-installer.md -2026-08-10-source-run-without-managed-installer.md: ecc4530b90a495f210476f100e894a7ce77cdb63 -2026-08-10-source-run-without-managed-installer.zh.md: 940bc6245bacc91abea4e2c746d81dbce4a50105 +2026-08-10-source-run-without-managed-installer.md: 85b98cabe18f038908a00d789c48197c36b80a3b +2026-08-10-source-run-without-managed-installer.zh.md: 4a322a48c23352cecfeccef3a93d3b782b8d3f80 diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md index ecc4530b90..85b98cabe1 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md @@ -12,7 +12,7 @@ That lifecycle is not required to run or develop DeepSeek Harness from a source ## Decision -The repository supports source execution through its root `pnpm` scripts. The `pnpm dsh ` launcher runs the complete repository build before launching the source CLI. It discards successful build output so CLI stdout remains machine-readable, reports failed build diagnostics on stderr, and sets `NODE_USE_ENV_PROXY=1` for the CLI process. Users select Web with `pnpm dsh web` and headless execution with `pnpm dsh run`. The independent ACP example remains available through `pnpm run demo:acp`. +The repository supports source execution through its root `pnpm` scripts. The `pnpm dsh ` launcher runs the complete repository build before launching the source CLI. It discards successful build output so CLI stdout remains machine-readable, reports failed build diagnostics on stderr, and sets `NODE_USE_ENV_PROXY=1` for the CLI process. Users select Web with `pnpm dsh web` and headless execution with `pnpm dsh --profile headless "task"`. The independent ACP example remains available through `pnpm run demo:acp`. The repository does not distribute a source installer, an installer test suite, or skills that assume a managed `current` symlink and timestamped staging worktrees. Users own source checkout placement, Git updates, and any launcher they create outside the repository. diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md index 940bc6245b..4a322a48c2 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -仓库通过根目录的 `pnpm` 脚本支持从源码运行。`pnpm dsh ` 启动器会先完成整个仓库的构建,再启动源码 CLI(命令行界面)。它会丢弃成功构建的输出,使 CLI stdout 保持机器可读;在 stderr 中报告构建失败的诊断信息;并为 CLI 进程设置 `NODE_USE_ENV_PROXY=1`。用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh run` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 +仓库通过根目录的 `pnpm` 脚本支持从源码运行。`pnpm dsh ` 启动器会先完成整个仓库的构建,再启动源码 CLI(命令行界面)。它会丢弃成功构建的输出,使 CLI stdout 保持机器可读;在 stderr 中报告构建失败的诊断信息;并为 CLI 进程设置 `NODE_USE_ENV_PROXY=1`。用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh --profile headless "task"` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 仓库不分发源码安装器、安装器测试套件,也不分发依赖受管理的 `current` 符号链接和带时间戳 staging worktree 的 skill。源码检出的存放位置、Git 更新,以及用户在仓库外创建的任何启动器均由用户负责。 diff --git a/AGENTS.md b/AGENTS.md index 09dbe67166..6d415a095f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (nee pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) -pnpm dsh run "task" # build, then run one task (needs DEEPSEEK_API_KEY) +pnpm dsh --profile headless "task" # build, then run one task (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # the agent modifies its own runtime (needs key) pnpm run demo:acp # ACP automation server (needs DEEPSEEK_API_KEY) ``` diff --git a/README.i18n.yaml b/README.i18n.yaml index e61b02e20a..552fb03a25 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: 3c68363198010d9b1f661aad9e8fb6766d10888e -README.zh.md: 2ac72a5d54c677b4f0ac3d51685929d07701f782 +README.md: b2d84672275a4ca996b6ea596c104abfaa52432a +README.zh.md: 747a3c88bc129ad5dd24f5fa150a1661c4db0b11 diff --git a/README.md b/README.md index 3c68363198..b2d8467227 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -pnpm dsh run "summarize this workspace" +pnpm dsh --profile headless "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index 2ac72a5d54..747a3c88bc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -49,7 +49,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -pnpm dsh run "summarize this workspace" +pnpm dsh --profile headless "summarize this workspace" ``` ### 自动化与 SDK diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 9044346ca7..9d8d8fba47 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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/development.md -development.md: 67e522dd183b953c511a24ee89e77892c9cef925 -development.zh.md: e6722e4254cb06551e5f97942040ddf42d3aa4e4 +development.md: 7d6ca74df1311560b0d61444c92e74927639ca31 +development.zh.md: 1461717cf496b1863f2f0c47ad4b42990dc789e9 diff --git a/docs/development.md b/docs/development.md index 67e522dd18..7d6ca74df1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -129,7 +129,7 @@ The root [contributor instructions](../AGENTS.md#commands) summarize common comm The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh -pnpm dsh run "summarize this workspace" +pnpm dsh --profile headless "summarize this workspace" ``` The self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`): diff --git a/docs/development.zh.md b/docs/development.zh.md index e6722e4254..1461717cf4 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -129,7 +129,7 @@ keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若 单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh -pnpm dsh run "summarize this workspace" +pnpm dsh --profile headless "summarize this workspace" ``` 自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`): diff --git a/packages/support/llm-mock-server/README.i18n.yaml b/packages/support/llm-mock-server/README.i18n.yaml index 48cd93b989..df99292c06 100644 --- a/packages/support/llm-mock-server/README.i18n.yaml +++ b/packages/support/llm-mock-server/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/support/llm-mock-server/README.md -README.md: f08c70d90b100e1c8d7f93e5e6a7e4cc30705c02 -README.zh.md: 8a3252ebac46de1494546a5a41acf13ff443f12d +README.md: 919bbc923110459e9ee4fc26f6db7e8b35ab1abb +README.zh.md: d60c6b2395c14c0cf690c2a94e902eb1cfeffa94 diff --git a/packages/support/llm-mock-server/README.md b/packages/support/llm-mock-server/README.md index f08c70d90b..919bbc9231 100644 --- a/packages/support/llm-mock-server/README.md +++ b/packages/support/llm-mock-server/README.md @@ -23,7 +23,7 @@ Point the shipping DeepSeek adapter at the server; it appends `/chat/completions ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ DEEPSEEK_API_KEY=mock-key \ -pnpm dsh run "test provider recovery" +pnpm dsh --profile headless "test provider recovery" ``` The repository script writes JSONL to stdout: a `ready` record carries the `/v1` base URL and random seed, followed by request/result records that name both the scripted behavior and the concrete selected behavior. The private support package exposes no installable binary. diff --git a/packages/support/llm-mock-server/README.zh.md b/packages/support/llm-mock-server/README.zh.md index 8a3252ebac..d60c6b2395 100644 --- a/packages/support/llm-mock-server/README.zh.md +++ b/packages/support/llm-mock-server/README.zh.md @@ -23,7 +23,7 @@ pnpm run mock:llm -- \ ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ DEEPSEEK_API_KEY=mock-key \ -pnpm dsh run "test provider recovery" +pnpm dsh --profile headless "test provider recovery" ``` 仓库脚本将 JSONL 写入 stdout:`ready` 记录携带以 `/v1` 结尾的基础 URL 和随机种子,后续请求/结果记录同时命名脚本行为和实际选中的具体行为。这个私有支持包不公开可安装的二进制命令。 diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 720632f490..904d38f1b4 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,19 +8,19 @@ }, { "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 run \"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" }, { "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 run \"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" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm dsh run \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm dsh run \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", From 6ad289809bdac10c873f78fc791231b37ba5cf01 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 10:47:08 +0800 Subject: [PATCH 62/77] fix(docs): keep upstream clone instructions --- docs/user/guide/quickstart.i18n.yaml | 4 ++-- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index f6c6096f40..68d0156d3a 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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/guide/quickstart.md -quickstart.md: f6537c71fc94096b2daf49827d041979346632bb -quickstart.zh.md: fbecabb964e3786929b3b942f49b5b44a4437e05 +quickstart.md: 635c0dffd6119f95c1951a441691f87f04396b4a +quickstart.zh.md: d0921382bcb0844fa0260c51546622c582867e68 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index f6537c71fc..635c0dffd6 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -19,7 +19,7 @@ pnpm -v ## Step 1: install and configure the API key ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index fbecabb964..d0921382bc 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -19,7 +19,7 @@ pnpm -v ## 第一步:安装并配置 API 密钥 ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness +git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git cd deepseek-harness pnpm install ``` From 9186824e87eb5b996add8ae6d87701f6457e5684 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 15:22:50 +0800 Subject: [PATCH 63/77] feat(session): refuse session logs a build cannot faithfully read Old runtimes meeting a newer session format now fail loud instead of misreading: version refusal names the direction (newer: upgrade the harness; older: no upgrade path) and points at the raw JSONL log, and an event type outside the generated known vocabulary refuses resume unless its envelope carries the new ignorable: true marker (default: required, so a forgotten marker over-refuses instead of silently resuming a gutted session). gen-persistence-catalog now also emits KNOWN_SESSION_EVENT_TYPES; SQLite stores the marker in a dedicated column (SCHEMA_VERSION 15). The versioning design (monotonic integer, n->n+1 upgrader chain, migrate-on-continue) is recorded in the session-log-version-mechanism Agent Note. --- ...10-session-log-version-mechanism.i18n.yaml | 6 + ...026-08-10-session-log-version-mechanism.md | 30 +++++ ...-08-10-session-log-version-mechanism.zh.md | 30 +++++ AGENTS.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 8 +- docs/event-producer-consumer.zh.md | 8 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 39 ++++--- docs/persistence-catalog.zh.md | 11 ++ docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 6 +- docs/subsystems/persistence.zh.md | 6 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 21 +++- docs/subsystems/session.zh.md | 21 +++- .../tests/session-format-guard.snapshot.ts | 107 ++++++++++++++++++ packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 5 +- packages/core/session/README.zh.md | 5 +- packages/core/session/src/index.ts | 5 +- .../core/session/src/known-event-types.ts | 59 ++++++++++ packages/core/session/src/types.ts | 30 ++++- packages/core/session/tests/session.spec.ts | 8 ++ .../host/apiproxy/src/api/sessions.schema.ts | 1 + .../tool-cordis/src/api-catalog.ts | 2 +- .../tests/jsonl.spec.ts | 17 +++ .../session-persistence-sqlite/src/index.ts | 27 +++-- .../session-persistence-sqlite/src/schema.ts | 7 +- .../tests/sqlite.spec.ts | 15 ++- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../session-persistence/src/coordinator.ts | 74 +++++++++++- .../session/session-persistence/src/index.ts | 1 + .../tests/coordinator-contract.ts | 58 +++++++++- scripts/gen-persistence-catalog.ts | 83 +++++++++++--- 40 files changed, 622 insertions(+), 106 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md create mode 100644 examples/headless-agent/tests/session-format-guard.snapshot.ts create mode 100644 packages/core/session/src/known-event-types.ts diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml new file mode 100644 index 0000000000..a5c4c2044f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.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-10-session-log-version-mechanism.md +2026-08-10-session-log-version-mechanism.md: 5358edfe15091379f5b0bbbe8e3e9d0580171c03 +2026-08-10-session-log-version-mechanism.zh.md: b790338c87c78cadda0744dc02d18a5000ffe5ff diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md new file mode 100644 index 0000000000..5358edfe15 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -0,0 +1,30 @@ +# Agent Note: Session log versioning — one integer, an upgrade chain, and a per-event ignorable marker + +Status: implemented + +English | [中文](2026-08-10-session-log-version-mechanism.zh.md) + +## Problem + +Session logs must be upgradable after release, and the runtime that ships first is the floor for every later decision: whatever refusal and degradation behavior is missing from the first released reader can never be added to the copies users already run. Release issue #1901 required at minimum that an old runtime reading a newer session format reports "unsupported" instead of misreading it. The pre-change reader did the opposite on both axes: `assertVersion` rejected any version mismatch with one direction-blind message, and the JSONL decoder passed unknown event types through untouched, so reconstruction silently skipped them — resuming a gutted session with no diagnostic at all. + +## Decision + +**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance (you rarely know at design time whether the next change will turn out "major"). This matches the SQLite backend's `SCHEMA_VERSION` precedent. + +**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers. + +**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing. + +**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example). + +## Consequences + +What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. + +## Alternatives considered + +- **Major/minor versioning** — the "is it convertible" bit lives on each step's upgrader, and pre-committing it into a number shape invites wrong promises. +- **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption. +- **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked. +- **Per-plugin runtime registration of known event types** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists. diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md new file mode 100644 index 0000000000..b790338c87 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -0,0 +1,30 @@ +# Agent Note:Session log 版本机制:单调整数、升级器链、逐事件可忽略标记 + +Status: implemented + +[English](2026-08-10-session-log-version-mechanism.md) | 中文 + +## 问题 + +Session log 在发布后必须能升级格式,而最先发布的运行时决定了此后一切的下限:第一个发布版的读取器缺少哪种拒绝和降级行为,用户手里已经装上的副本就永远补不上。发布 issue #1901 的最低要求是老运行时读到新 Session 格式时明确报不支持,而不是读错。改动前的读取器在两个方向上都做反了:`assertVersion` 对任何版本不匹配抛出同一条不区分方向的消息;JSONL 解码器把不认识的事件类型原样放行,重建时静默跳过,恢复出一个内容残缺的会话且没有任何诊断。 + +## 决定 + +**一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺(设计时很少能预知下一个变更算不算"大")。这与 SQLite 后端 `SCHEMA_VERSION` 的先例一致。 + +**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。 + +**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。 + +**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。 + +## 影响 + +v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。 + +## 曾考虑的替代方案 + +- **大小两级版本号**:能否转换这一位信息属于每一步的升级器,把它预先固化进编号形状会做出错误承诺。 +- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。 +- **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。 +- **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。 diff --git a/AGENTS.md b/AGENTS.md index adc14858d7..e8ed4bc73d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,7 +100,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)). -- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. +- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. A `SessionEventMap` member is required-on-read by default — builds that do not know its type refuse the log unless the event carries the envelope's `ignorable: true`; only structural format changes bump `SESSION_FORMAT_VERSION` ([mechanism](.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)). - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). - **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event. diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2f05b260a2..bc972314e8 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: 646198cea4d30ddc799ef4886af309f376cfa9f2 -config-catalog.zh.md: cda44f7904196fe2bf401fed2dc5b5e8b28ccf1c +config-catalog.md: cc50a2021cfc378481dc3e830a0973773bae1e02 +config-catalog.zh.md: 5ed4f54a60130927ba710cae1f041da397c2d77a diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 646198cea4..cc50a2021c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1441,7 +1441,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session/session-persistence-sqlite/src/index.ts:67`](../packages/session/session-persistence-sqlite/src/index.ts) +Source: [`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-projection-cache` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index cda44f7904..5ed4f54a60 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1443,7 +1443,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -来源:[`packages/session/session-persistence-sqlite/src/index.ts:67`](../packages/session/session-persistence-sqlite/src/index.ts) +来源:[`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-projection-cache` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 8dd7e6321a..e40160c27b 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: 19e2e660e58101b54054091b3d55b362d25d29dc -event-producer-consumer.zh.md: d29cab8974d206b74b8869057b8efcf7542c1161 +event-producer-consumer.md: 33e3f8e67291d9f3b50d9a52fc3104e2e218d799 +event-producer-consumer.zh.md: 2f036ba0cad1d86952424c4d4969795a3862cfd7 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 19e2e660e5..33e3f8e672 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,10 +30,10 @@ 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/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/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) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:75`](../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/disposed` | `emit` | [`packages/core/session/src/index.ts:85`](../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:97`](../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/flush` | `parallel` | [`packages/core/session/src/index.ts:106`](../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) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index d29cab8974..2f036ba0ca 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -32,10 +32,10 @@ | `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/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/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) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:75`](../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/disposed` | `emit` | [`packages/core/session/src/index.ts:85`](../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:97`](../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/flush` | `parallel` | [`packages/core/session/src/index.ts:106`](../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) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 5778b13667..12fe94e64c 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: 1b94ecc541f2b9da216a5d10e02a8a5aa46f7cfb -persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 +persistence-catalog.md: 2b150ba09eea4365fd0559c68d6f9499ae336933 +persistence-catalog.zh.md: 0ca78a63e85705aaba9c9727c22509891670f42d diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1b94ecc541..2b150ba09e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -63,6 +63,17 @@ export type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources @@ -79,7 +90,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:367`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:399`](../packages/core/session/src/types.ts) ## Events @@ -192,7 +203,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/inter Types: [StreamChunk](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -208,7 +219,7 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) ### `command/*` @@ -488,7 +499,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -500,7 +511,7 @@ Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -553,7 +564,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -589,7 +600,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sessi 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -598,7 +609,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -628,7 +639,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `tool/*` @@ -645,7 +656,7 @@ Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -714,7 +725,7 @@ 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) +Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) ### `turn/*` @@ -734,7 +745,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -748,7 +759,7 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) ### `user/*` @@ -765,7 +776,7 @@ Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 21ed29a3da..0ca78a63e8 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -65,6 +65,17 @@ export type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 65925a1608..b500928227 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: 0266d17393d07c258036f7054a02c4ab9d3c74a2 -persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6 +persistence.md: de7c5c4d445986fe306a782683a8559b25677c94 +persistence.zh.md: a52506aa86418f66e6b1a372020cc316f67cc1c7 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 0266d17393..de7c5c4d44 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -87,6 +87,10 @@ interface SessionHeader { } ``` +## Format refusal — logs a build cannot faithfully read + +A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). + ## `CreateSessionOptions` — seeding and metadata Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, the `agentPreset` the agent was composed from, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. @@ -342,5 +346,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index ced8344016..a52506aa86 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -87,6 +87,10 @@ interface SessionHeader { } ``` +## 格式拒绝:本构建无法可靠读取的日志 + +后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。 + ## `CreateSessionOptions`:seed 与元数据 通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`、该 agent 所依据组装的 `agentPreset` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 @@ -342,5 +346,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 7ba2ae289d..177e33013d 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/session.md -session.md: 0b78e51ebf6e2ad5c312268ad4bfb4392b0486df -session.zh.md: d1e91f684a835e08406f524efe876baa1a6a72cb +session.md: 990b249cde9f02343f2c668aee5d7c000837df56 +session.zh.md: 39e8ff1e8831fd75c8929c93e263622bb5aa6ea4 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 0b78e51ebf..990b249cde 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -215,6 +215,17 @@ type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources @@ -733,7 +744,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:813`](../../packages/core/session/src/index.ts) @@ -762,7 +773,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts) @@ -785,7 +796,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:85`](../../packages/core/session/src/index.ts) @@ -810,7 +821,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:97`](../../packages/core/session/src/index.ts) @@ -832,5 +843,5 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:106`](../../packages/core/session/src/index.ts) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d1e91f684a..39e8ff1e88 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -217,6 +217,17 @@ type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources @@ -737,7 +748,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:813`](../../packages/core/session/src/index.ts) @@ -766,7 +777,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts) @@ -789,7 +800,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:85`](../../packages/core/session/src/index.ts) @@ -814,7 +825,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:97`](../../packages/core/session/src/index.ts) @@ -836,5 +847,5 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:106`](../../packages/core/session/src/index.ts) diff --git a/examples/headless-agent/tests/session-format-guard.snapshot.ts b/examples/headless-agent/tests/session-format-guard.snapshot.ts new file mode 100644 index 0000000000..d7f327b6b6 --- /dev/null +++ b/examples/headless-agent/tests/session-format-guard.snapshot.ts @@ -0,0 +1,107 @@ +/** + * Assembled-app regression for the session-format refusal surface: resuming a + * log written by a "newer" harness (format version ahead, or an unknown + * required event type) fails loud through the real Loader composition, and the + * error the product user sees names the direction and the raw log path. + * @module session-format-guard-snapshot + */ + +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from 'cordis' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import SessionStore, { + SESSION_FORMAT_VERSION, + SessionId, + type SessionEvent, + type SessionHeader, +} from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { describe, expect, it } from 'vitest' + +const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit') +const replayFixture = join(fixtureDir, 'replay.jsonl') +const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +// The resumed-agent fixture in the shared config resumes exactly this id. +const sessionId = SessionId('workspace-context-resume') + +/** Persist one session with the given header version and events, returning its log path. */ +async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + const meta: SessionHeader = { version, id: sessionId, createdAt: 1, cwd } + try { + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(sessionId, events) + const location = ctx.sessionPersistence.locate(meta) + if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') + return location.path + } finally { + await ctx.fiber.dispose() + } +} + +function closedTurn(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] +} + +describe('session format guard through the assembled app', () => { + it('refuses to resume a newer-format log, naming the upgrade direction and the raw log path', async () => { + let sessionPath = '' + const result = await runLoaderSmoke({ + label: 'newer-format resume refusal', + tempDirPrefix: 'dsh-format-guard-version-', + binScript, + libBinScript: binScript, + configPath, + binArgs: [configPath, 'Try to resume.'], + tsconfigPath, + env: { DSH_SNAPSHOT_FILE: replayFixture }, + expectedExitCode: 1, + prepare: async (runCwd) => { + sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn()) + }, + }) + expect(result.stderr).toContain( + `session "${sessionId}" uses log format v${SESSION_FORMAT_VERSION + 99}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`, + ) + // macOS reports the temp dir via the /private symlink parent; assert the + // stable path suffix instead of the realpath-dependent prefix. + expect(result.stderr).toContain('(raw log: ') + expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/'))) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('refuses to resume a log with an unknown required event type', async () => { + let sessionPath = '' + const result = await runLoaderSmoke({ + label: 'unknown-event resume refusal', + tempDirPrefix: 'dsh-format-guard-event-', + binScript, + libBinScript: binScript, + configPath, + binArgs: [configPath, 'Try to resume.'], + tsconfigPath, + env: { DSH_SNAPSHOT_FILE: replayFixture }, + expectedExitCode: 1, + prepare: async (runCwd) => { + sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [ + ...closedTurn(), + { type: 'future/event', seq: 2, time: 3, data: { payload: 1 } } as unknown as SessionEvent, + ]) + }, + }) + expect(result.stderr).toContain( + `session "${sessionId}" contains event type "future/event" (seq 2) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`, + ) + // macOS reports the temp dir via the /private symlink parent; assert the + // stable path suffix instead of the realpath-dependent prefix. + expect(result.stderr).toContain('(raw log: ') + expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/'))) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index c5ed6a2c98..72d20a2818 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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/core/session/README.md -README.md: db477d94037d3463870fc8e66ea35d5e607fb6fe -README.zh.md: 1ce1e823a7e0fdbcf7b6898764a89c52b74adf6a +README.md: 57569e9c0dbfa7cb696e3a561a9ff108c2ac981f +README.zh.md: 16629dc70c79ca838ba7088aeafcc5b38b124f87 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index db477d9403..57569e9c0d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -76,10 +76,11 @@ Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`. -Every `SessionEvent` carries two optional top-level fields (structural metadata): +Every `SessionEvent` carries three optional top-level fields (structural metadata): - `sourceEventSeqs?: number[]` — seq numbers of earlier events cited as sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed entries behind a compaction replacement entry). On `assistant/message`, a present `[]` records a known empty provider stream, while omission means a legacy or foreign event did not record the source stream; other surface events require a non-empty list when this field is present. - `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors). +- `ignorable?: true` — marks an event a reader may safely skip when it does not recognize the type; absent means required, so an unknown-type event refuses session reconstruction ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)). ### Metadata types (`types.ts`) @@ -139,5 +140,5 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi - **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`. - **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md). -- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes and a backend rejects any other version. Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)). +- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes, and a backend refuses any other version naming the direction (newer: "written by a newer harness — upgrade"; older: no upgrade path ships yet). Unknown event types refuse the same way unless marked `ignorable` in the envelope; the versioning mechanism is the [session-log-version-mechanism note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)). - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 1ce1e823a7..16629dc70c 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -76,10 +76,11 @@ 被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。 -每个 `SessionEvent` 都有两个可选顶层字段(结构元数据): +每个 `SessionEvent` 都有三个可选顶层字段(结构元数据): - `sourceEventSeqs?: number[]`:被引用为来源的较早事件 seq(例如 `assistant/message` 引用的 `assistant/chunk` seq,或压缩替换条目引用的已遮蔽条目)。对于 `assistant/message`,存在的 `[]` 表示已知提供方流为空;省略则表示旧版或外部事件没有记录源流。其他 surface 事件若有此字段,则要求非空列表。 - `surfaceOp?: SurfaceOp`:事件进入 surface 的方式。非 surface 事件(边界、分片、用量、错误)不含该字段。 +- `ignorable?: true`:标记读取器在不认识事件类型时可以安全跳过该事件;缺失表示必需,不认识的事件类型会使会话重建被拒绝([机制](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md))。 ### 元数据类型(`types.ts`) @@ -139,5 +140,5 @@ - **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 -- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端会拒绝其他任何版本。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。 +- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝其他任何版本并说明方向(更新的版本提示"由更新的 harness 写入,请升级";更旧的版本说明尚无升级路径)。不认识的事件类型同样被拒绝,除非信封带 `ignorable` 标记;版本机制见 [session-log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index dd1f7b9175..5251ca9408 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -32,6 +32,7 @@ export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' +export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts' /** * Find the latest closed turn that entered at least one model step, ignoring @@ -243,6 +244,7 @@ function assertSessionEventEnvelope(value: Record, index: numbe case 'data': case 'surfaceOp': case 'sourceEventSeqs': + case 'ignorable': break default: throw new Error(`seed event at index ${index} has an invalid event envelope`) @@ -254,7 +256,8 @@ function assertSessionEventEnvelope(value: Record, index: numbe if (typeof type !== 'string' || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 || typeof time !== 'number' || !Number.isSafeInteger(time) - || event['data'] === undefined) { + || event['data'] === undefined + || (event['ignorable'] !== undefined && event['ignorable'] !== true)) { throw new Error(`seed event at index ${index} has an invalid event envelope`) } switch (type) { diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts new file mode 100644 index 0000000000..2c2b5487bb --- /dev/null +++ b/packages/core/session/src/known-event-types.ts @@ -0,0 +1,59 @@ +/** + * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run + * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by + * `pnpm run verify-persistence-catalog`, part of `doc-sync`). + * @module @deepseek-ai/dsh-session/known-event-types + */ + +/** + * Every `SessionEventMap` member declared in this repository — the event + * vocabulary this build understands. The persistence read path refuses to + * interpret a log containing a type outside this set unless the event + * carries the envelope's `ignorable` marker (see `SessionEvent.ignorable` + * in `./types.ts`): such a log was likely written by a newer harness, and + * silently skipping a required event would reconstruct a wrong session. + * Downstream (out-of-repo) plugin events are outside this list by + * construction; a registration surface for them is deferred until such a + * consumer exists. + */ +export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ + 'agent-preset/selected', + 'agent/inbox/spliced', + 'approval/asked', + 'approval/decided', + 'approval/policy', + 'assistant/chunk', + 'assistant/message', + 'command/done', + 'command/run', + 'compact/end', + 'compact/prune', + 'compact/start', + 'compact/summary', + 'feedback/record', + 'goal/change', + 'hook/invoked', + 'hook/result', + 'llm/retry', + 'llm/retry-started', + 'permission/preset', + 'plan/mode', + 'request/context', + 'request/header', + 'sandbox/mode', + 'session/end-seed', + 'session/title', + 'session/title-llm-request', + 'step/end', + 'step/start', + 'subagent/descriptor', + 'todo/write', + 'tool/call', + 'tool/code-dispatch', + 'tool/code-dispatch-start', + 'tool/result', + 'turn/end', + 'turn/start', + 'user/message', + 'web/deepseek-search-llm-request', +]) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 35dd9d1dab..9e50c18d11 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -30,8 +30,23 @@ export function SessionId(id: string): SessionId { * and enforced by every persistence backend on load. The single source of truth for the * version — write sites and the load-time check all read it. * While the harness is unreleased it is pinned at `0`: no compatibility is - * implied, incompatible logs are rejected, and no migration is provided. A - * monotonic version policy starts with the first tagged release. + * implied, incompatible logs are rejected, and no migration is provided. + * + * The version is a single monotonic integer with no major/minor split. Whether + * a bump is needed is decided by what the WRITER emits, never by what a newer + * reader can accept: bump exactly when an older runtime could no longer handle + * a new log with full semantic correctness ("parses without error" is not + * correctness — silently skipping content that shapes reconstruction is a + * wrong read). Only structural changes reach that bar: the header shape, the + * {@link SessionEvent} envelope, core event semantics, or the surface + * mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants). + * Adding an ordinary event type does not bump — the per-event + * {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When + * in doubt, bump: a near-identity upgrade step is almost free, a missed bump + * makes older runtimes read new logs wrong silently. The full mechanism + * (upgrade-step chain, in-memory view conversion, migrate-on-continue) is + * recorded in the session-log-version-mechanism Agent Note + * (`.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md`). */ export const SESSION_FORMAT_VERSION = 0 @@ -389,6 +404,17 @@ export type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 779a24e748..7fe7ee01c7 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1090,12 +1090,20 @@ describe('Session', () => { { ...base, time: '1' }, { ...base, time: 0.5 }, { type: base.type, seq: base.seq, time: base.time }, + { ...base, ignorable: false }, + { ...base, ignorable: 'yes' }, ] for (const [index, event] of cases.entries()) { expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent])) .toThrow(/invalid event envelope/) } + + // `ignorable: true` is the one accepted marker value (unknown-type skip contract). + const marked = Session.create(SessionId('ignorable-envelope'), [ + { ...base, ignorable: true } as SessionEvent, + ]) + expect(marked.events[0]?.ignorable).toBe(true) }) }) diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 81e150bc20..5c4647769a 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -45,6 +45,7 @@ export const sessionEventSchema = z.object({ data: z.unknown(), sourceEventSeqs: z.array(z.number()).optional(), surfaceOp: z.unknown().optional(), + ignorable: z.literal(true).optional(), }) as unknown as z.ZodType /** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 29965759bc..d177f49d29 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -2633,7 +2633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEvent', - declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', + declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', }, { name: 'SessionEventMap', diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index cdb239a982..70781a5d20 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -186,6 +186,23 @@ describe('SessionPersistenceJsonl: format helpers', () => { }) await fiber.dispose() }) + + it('points a format refusal at the raw log path', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + const m = { ...meta('newer-format', '/work'), version: 7 } + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ]) + const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain(`(raw log: ${rawLogPath(resolve(absoluteRoot), '/work', m.id)})`) + await fiber.dispose() + }) }) describe('SessionPersistenceJsonl: durability and crash semantics', () => { diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index ab674469d9..15cf869b69 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -28,15 +28,18 @@ import { export { SCHEMA_VERSION } from './schema.ts' /** - * Serialize an event's surface-metadata fields for SQL binding. Both fields are - * nullable TEXT columns — null when the event has no surface metadata (non-surface - * events, events written before surface support). + * Serialize an event's optional envelope fields for SQL binding. The surface + * fields are nullable TEXT columns — null when the event has no surface + * metadata (non-surface events, events written before surface support); the + * ignorable marker is a nullable INTEGER column — `1` iff the envelope carries + * `ignorable: true`. */ -function surfaceBindings(event: SessionEvent): [string | null, string | null] { +function envelopeBindings(event: SessionEvent): [string | null, string | null, number | null] { const se = event as SessionEvent return [ se.sourceEventSeqs ? JSON.stringify(se.sourceEventSeqs) : null, se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null, + event.ignorable === true ? 1 : null, ] } @@ -225,7 +228,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers if (row === undefined) return undefined const meta = rowToMeta(row) const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') .all(id, fromSeq) as unknown as EventRow[] signal?.throwIfAborted() const { preserved } = scanRows(eventRows, fromSeq) @@ -247,7 +250,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const row = this.rowFor(id) if (row !== undefined) { const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq') + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq') .all(id) as unknown as EventRow[] snapshot = { row, eventRows } } @@ -279,14 +282,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { await this.ready const insertEvent = this.db.prepare( - 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', ) this.db.exec('BEGIN') try { if (!isMaterialized) this.writeRow(meta) for (const event of events) { - const [surfaceSeqs, surfaceOp] = surfaceBindings(event) - insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp) + const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event) + insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable) } this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id) this.db.exec('COMMIT') @@ -310,11 +313,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers } if (closers.length > 0) { const insertEvent = this.db.prepare( - 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', ) for (const event of closers) { - const [surfaceSeqs, surfaceOp] = surfaceBindings(event) - insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp) + const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event) + insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable) } } if (tornMarker !== undefined || closers.length > 0) { diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index c7a4de7233..c7402d7d4b 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 14 +export const SCHEMA_VERSION = 15 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -55,6 +55,8 @@ export interface EventRow { source_event_seqs: string | null /** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */ surface_op: string | null + /** `1` iff the event carries the envelope's `ignorable: true` marker, else null. */ + ignorable: number | null } /** @@ -139,6 +141,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM data TEXT NOT NULL, source_event_seqs TEXT, surface_op TEXT, + ignorable INTEGER, PRIMARY KEY (session_id, seq) ) STRICT `) @@ -203,12 +206,14 @@ export function rowToEvent(row: EventRow): SessionEvent { ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {}, ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {}, } + const ignorableField = row.ignorable === 1 ? { ignorable: true as const } : {} return { type: row.type as SessionEvent['type'], seq: row.seq, time: row.time, data: JSON.parse(row.data) as SessionEvent['data'], ...surfaceFields, + ...ignorableField, } as SessionEvent } diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index 5f8f910bde..bec3a11dad 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -92,6 +92,7 @@ describe('scanRows', () => { seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data), source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null, surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null, + ignorable: e.ignorable === true ? 1 : null, } }) @@ -142,8 +143,8 @@ describe('scanRows', () => { it('throws on an unparsable row inside the committed region', () => { const withCorruptCommitted: EventRow[] = [ - { seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end - { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null }, + { seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // corrupt, sits before a turn/end + { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null, ignorable: null }, ] expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/) }) @@ -151,7 +152,7 @@ describe('scanRows', () => { it('tolerates an unparsable torn-tail row after the last turn/end', () => { const withCorruptTail: EventRow[] = [ ...rows(oneTurnLog()), - { seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null }, // torn fragment, no committed turn/end after + { seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // torn fragment, no committed turn/end after ] const { preserved, tornFrom } = scanRows(withCorruptTail) expect(preserved).toEqual(oneTurnLog()) @@ -658,7 +659,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(14) + expect(SCHEMA_VERSION).toBe(15) }) it('keeps the revision stable for an empty repair hook', async () => { @@ -857,6 +858,7 @@ describe('surface field round-trip', () => { data: JSON.stringify({ turn: 1, step: 1, content: [] }), source_event_seqs: JSON.stringify([3, 5]), surface_op: JSON.stringify('append'), + ignorable: null, } const event = rowToEvent(row) expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5]) @@ -869,6 +871,7 @@ describe('surface field round-trip', () => { data: JSON.stringify({ turn: 1, step: 1, content: [] }), source_event_seqs: JSON.stringify([0, 1]), surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }), + ignorable: null, } const event = rowToEvent(row) expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) @@ -879,10 +882,10 @@ describe('surface field round-trip', () => { const rows: EventRow[] = [ { seq: 0, type: 'user/message', time: 1, data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }), - source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' }, + source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}', ignorable: null }, { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), - source_event_seqs: null, surface_op: null }, + source_event_seqs: null, surface_op: null, ignorable: 1 }, ] const { preserved } = scanRows(rows) expect(preserved).toHaveLength(2) diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 15808bb5e4..edb755197c 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: 391548b1b896dca14cbe4f4ae55cf4180c4e0ac2 -README.zh.md: 7213e1ee71ba418ffacc3685df371dcba33588a7 +README.md: 7e62360ccf47151f5c450685bfebe6e89bbf187b +README.zh.md: 3d819ef0ab4f85c83c2e640f627e36341318ac35 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 391548b1b8..7e62360ccf 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -14,7 +14,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `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. | -| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed records, and unknown `version` reject. | +| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | | `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 7213e1ee71..3d819ef0ab 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -14,7 +14,7 @@ | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | -| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的记录和未知 `version` 会被拒绝。 | +| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | | `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index be1edf01c3..6049868f98 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { adoptSessionEvent, interruptedTurnClosers, + KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, SessionPreparation, snapshotJsonValue, @@ -16,7 +17,7 @@ import { } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import type { SessionInspection } from './index.ts' +import type { SessionInspection, SessionLocation } from './index.ts' import type { SessionPersistenceRevision } from './revision.ts' import { observeQueuedAbort, SessionPreparations } from './preparations.ts' import type { SessionPreparationReservation } from './preparations.ts' @@ -43,6 +44,26 @@ export class SessionPersistenceCorruptionError extends Error { } } +/** + * The stored log is intact but this runtime cannot faithfully interpret it: + * the header carries an unsupported format version, or an event's type is + * unknown to this build and the event is not marked ignorable. Distinct from + * {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log + * remains readable at {@link location} when the backend keeps one artifact + * per session. + */ +export class SessionFormatUnsupportedError extends Error { + /** + * @param message - stable reason the log cannot be interpreted, already + * including the raw-log path when one exists. + * @param location - the backend's artifact location, when one exists. + */ + constructor(message: string, readonly location?: SessionLocation) { + super(message) + this.name = 'SessionFormatUnsupportedError' + } +} + /** Coordinator policy supplied by a concrete persistence backend. */ export interface PersistenceCoordinatorOptions { /** Maximum completed unpublished preparations retained for reuse. */ @@ -156,6 +177,14 @@ export interface PersistenceBackend { */ list(signal?: AbortSignal): Promise + /** + * Optional side-effect-free artifact locator, used to point refusal + * diagnostics ({@link SessionFormatUnsupportedError}) at the raw log. + * Backends without one artifact per session omit it or return `undefined`. + * @param meta - the header whose artifact is requested. + */ + locate?(meta: SessionHeader): SessionLocation | undefined + /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the * coordinator's dispose effect AFTER the quiescence drain. A stateless file @@ -806,7 +835,9 @@ export class PersistenceCoordinator { const whole = await this.readStoredPrefix(id, signal) return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } } - return { meta: structuredClone(suffix.meta), events: snapshotStoredEvents(suffix.events, id) } + const events = snapshotStoredEvents(suffix.events, id) + this.assertEventsSupported(suffix.meta, events) + return { meta: structuredClone(suffix.meta), events } } const whole = await this.readStoredPrefix(id, signal) // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. @@ -824,9 +855,11 @@ export class PersistenceCoordinator { if (stored === undefined) throw new Error(`session "${id}" not found`) this.assertStoredId(id, stored.meta) this.assertVersion(stored.meta) + const events = snapshotStoredEvents(stored.events, id) + this.assertEventsSupported(stored.meta, events) return { meta: structuredClone(stored.meta), - events: snapshotStoredEvents(stored.events, id), + events, } } @@ -839,6 +872,7 @@ export class PersistenceCoordinator { this.assertStoredId(id, meta) this.assertVersion(meta) const storedEvents = adoptStoredEvents(events, id) + this.assertEventsSupported(meta, storedEvents) // Preserve complete interrupted events and synthesize only missing closers. const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent) @@ -861,6 +895,9 @@ export class PersistenceCoordinator { closers, } } catch (error: unknown) { + // An unsupported format is a refusal over an intact log, not damage — + // surface it unwrapped so callers can point at the raw artifact. + if (error instanceof SessionFormatUnsupportedError) throw error throw new SessionPersistenceCorruptionError( `stored session "${id}" failed validation: ${String(error)}`, { cause: error }, @@ -982,11 +1019,38 @@ export class PersistenceCoordinator { } private assertVersion(meta: SessionHeader): void { - if (meta.version !== SESSION_FORMAT_VERSION) { - throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`) + if (meta.version === SESSION_FORMAT_VERSION) return + throw this.unsupported(meta, meta.version > SESSION_FORMAT_VERSION + ? `session "${meta.id}" uses log format v${meta.version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` + : `session "${meta.id}" uses log format v${meta.version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`) + } + + /** + * Refuse a log containing an event type this build does not know, unless the + * writer marked the event ignorable: an unrecognized required event may + * change how the rest of the log must be interpreted, so silently skipping + * it would reconstruct a wrong session (the envelope contract on + * `SessionEvent.ignorable`). Runs on NORMALIZED events — after + * `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes + * this build still reads and rejected the ones it does not, so those keep + * their specific diagnostics. + */ + private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void { + for (const event of events) { + if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue + throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`) } } + /** Build a format refusal that points at the raw artifact when the backend has one. */ + private unsupported(meta: SessionHeader, reason: string): SessionFormatUnsupportedError { + const location = this.backend.locate?.(meta) + return new SessionFormatUnsupportedError( + location === undefined ? reason : `${reason} (raw log: ${location.path})`, + location, + ) + } + /** Reject backend metadata that is not bound to the requested session id. */ private assertStoredId(id: SessionId, meta: SessionHeader): void { if (meta.id !== id) { diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index dc06517367..62941477ff 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -36,6 +36,7 @@ export { DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, PersistenceCoordinator, + SessionFormatUnsupportedError, SessionPersistenceCorruptionError, } from './coordinator.ts' export type { diff --git a/packages/session/session-persistence/tests/coordinator-contract.ts b/packages/session/session-persistence/tests/coordinator-contract.ts index c272109f82..8633faa886 100644 --- a/packages/session/session-persistence/tests/coordinator-contract.ts +++ b/packages/session/session-persistence/tests/coordinator-contract.ts @@ -706,6 +706,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< .rejects.toThrow('lacks an identified message') } + // An out-of-repo event type passes only with the envelope's ignorable + // marker (unknown-type refusal otherwise), and its non-object data is + // not message-validated. const pluginId = SessionId('non-object-plugin-event') await ctx.sessionPersistence.create(meta(pluginId, WORK)) await ctx.sessionPersistence.append(pluginId, [{ @@ -713,11 +716,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< seq: 0, time: 1, data: null, + ignorable: true, } as unknown as SessionEvent]) await expect(ctx.sessionPersistence.inspect(pluginId)) - .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] }) + .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] }) await expect(ctx.sessionPersistence.readFrom(pluginId, 0)) - .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] }) + .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] }) for (const type of ['user/message', 'assistant/message'] as const) { const missingContentId = SessionId(`invalid-${type}-without-content`) @@ -1321,14 +1325,60 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('rejects an unknown format version on load (assertVersion)', async () => { + it('rejects a newer format version on load, naming the upgrade direction', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) - await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/version/) + const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('rejects an older format version on load without claiming an upgrade path', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const m = { version: -1, id: SessionId('v-older'), createdAt: 1, cwd: WORK } + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/older than the supported v0.*no upgrade path/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('rejects an unknown event type on load unless the event is marked ignorable', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const required = meta('unknown-required', WORK) + await ctx.sessionPersistence.create(required) + await ctx.sessionPersistence.append(required.id, [ + ...oneTurnLog(), + { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 } } as unknown as SessionEvent, + ]) + const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/event type "future\/event".*not marked ignorable/) + + const skippable = meta('unknown-ignorable', WORK) + await ctx.sessionPersistence.create(skippable) + await ctx.sessionPersistence.append(skippable.id, [ + ...oneTurnLog(), + { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 }, ignorable: true } as unknown as SessionEvent, + ]) + const loaded = await ctx.sessionPersistence.load(skippable.id) + expect(loaded.events.some(event => (event.type as string) === 'future/event')).toBe(true) } finally { await fiber.dispose() await fix.cleanup() diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 173d4222cb..e95f78a99a 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -13,6 +13,7 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/persistence-catalog.md' +const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts' /** The fenced-block info string for generated declaration blocks (skipped by * doc-typecheck, since their imported types are not standalone-compilable). */ @@ -382,31 +383,79 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv return lines.join('\n') } -/** CLI entry: default writes the catalog, `--check` fails if the committed copy +/** + * Render the runtime known-vocabulary module: every event type the packages in + * this repo can write, as a generated `ReadonlySet` the read path checks + * unknown-type refusal against (`SessionEvent.ignorable` contract). + */ +export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string { + const names = [...new Set(events.map(e => e.name))].sort() + return [ + '/**', + ' * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run', + ' * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by', + ' * `pnpm run verify-persistence-catalog`, part of `doc-sync`).', + ' * @module @deepseek-ai/dsh-session/known-event-types', + ' */', + '', + '/**', + ' * Every `SessionEventMap` member declared in this repository — the event', + ' * vocabulary this build understands. The persistence read path refuses to', + ' * interpret a log containing a type outside this set unless the event', + ' * carries the envelope\'s `ignorable` marker (see `SessionEvent.ignorable`', + ' * in `./types.ts`): such a log was likely written by a newer harness, and', + ' * silently skipping a required event would reconstruct a wrong session.', + ' * Downstream (out-of-repo) plugin events are outside this list by', + ' * construction; a registration surface for them is deferred until such a', + ' * consumer exists.', + ' */', + 'export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([', + ...names.map(name => ` '${name}',`), + '])', + '', + ].join('\n') +} + +/** One generated artifact: repo-relative target and its freshly-rendered content. */ +interface GeneratedArtifact { + readonly out: string + readonly content: string +} + +/** CLI entry: default writes the artifacts, `--check` fails if a committed copy * is stale. Guarded behind an entry-point check so importing this module for - * tests neither regenerates the committed file nor calls process.exit. */ + * tests neither regenerates the committed files nor calls process.exit. */ function main(): void { - const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes()) + const events = annotateSurface(collectLogEvents(), collectSurfaceEventTypes()) + const artifacts: GeneratedArtifact[] = [ + { out: OUT, content: render(events, collectEventEnvelopeTypes()) }, + { out: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) }, + ] if (process.argv.includes('--check')) { - let committed: string | null = null - try { - committed = readFileSync(resolve(root, OUT), 'utf8') - } catch { - // Only ENOENT (not yet generated) is expected; a present-but-unreadable - // file is not a state this repo produces. Either way the remedy is the - // same — regenerate — so treat a read failure as "stale". - committed = null - } - if (committed === content) { - console.log(`gen-persistence-catalog: ${OUT} is up to date.`) + const stale = artifacts.filter((artifact) => { + let committed: string | null = null + try { + committed = readFileSync(resolve(root, artifact.out), 'utf8') + } catch { + // Only ENOENT (not yet generated) is expected; a present-but-unreadable + // file is not a state this repo produces. Either way the remedy is the + // same — regenerate — so treat a read failure as "stale". + committed = null + } + return committed !== artifact.content + }) + if (stale.length === 0) { + console.log(`gen-persistence-catalog: ${artifacts.map(a => a.out).join(', ')} are up to date.`) process.exit(0) } - console.error(`gen-persistence-catalog: ${OUT} is stale. Run \`pnpm run gen-persistence-catalog\` and commit ${OUT}.`) + console.error(`gen-persistence-catalog: ${stale.map(a => a.out).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`) process.exit(1) } - writeFileSync(resolve(root, OUT), content) - console.log(`gen-persistence-catalog: wrote ${OUT}.`) + for (const artifact of artifacts) { + writeFileSync(resolve(root, artifact.out), artifact.content) + console.log(`gen-persistence-catalog: wrote ${artifact.out}.`) + } } // Run only when invoked as a script, not when imported by a test. From 732bcb7ef1c96d42aaefb55b95770c140b8e9549 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 15:44:17 +0800 Subject: [PATCH 64/77] test(acp): re-record cordis-inspect-jsdoc snapshot for the ignorable envelope field --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 6fa938c18e..8173c4aa07 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"d422878c-c566-461b-9b6b-a61d241022ea"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"eb20999e-deb2-4abe-8517-14de8a6ca238"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From 0a95a9eed85e2a1b98549a21614810c536eab681 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 15:53:16 +0800 Subject: [PATCH 65/77] fix(session): refuse foreign format versions before parsing current structure Review round: the JSONL backend now refuses a foreign header version straight from the raw header line, before validating today's header shape or decoding any event row, so a structurally different future format reports the upgrade direction instead of corruption (shared message builder sessionFormatVersionRefusal). HMR live-prefix adoption runs the unknown-type guard like the other read paths. The appendCore comment now states why the unknown-type guard is read-side only, the loadStoredFrom JSDoc and README pin the seek-vs-sequential refusal-scope divergence, and the generated catalog preamble lists the ignorable envelope field. --- ...10-session-log-version-mechanism.i18n.yaml | 4 +-- ...026-08-10-session-log-version-mechanism.md | 2 +- ...-08-10-session-log-version-mechanism.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/subsystems/persistence.i18n.yaml | 4 +-- docs/subsystems/persistence.md | 4 +-- docs/subsystems/persistence.zh.md | 4 +-- .../session-persistence-jsonl/src/format.ts | 20 ++++++++++- .../session-persistence-jsonl/src/index.ts | 36 ++++++++++++------- .../tests/jsonl.spec.ts | 21 ++++++++++- .../session-persistence/README.i18n.yaml | 4 +-- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../session-persistence/src/coordinator.ts | 36 +++++++++++++++---- .../session/session-persistence/src/index.ts | 1 + scripts/gen-persistence-catalog.ts | 2 +- 18 files changed, 112 insertions(+), 40 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml index a5c4c2044f..ee249b18ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.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-10-session-log-version-mechanism.md -2026-08-10-session-log-version-mechanism.md: 5358edfe15091379f5b0bbbe8e3e9d0580171c03 -2026-08-10-session-log-version-mechanism.zh.md: b790338c87c78cadda0744dc02d18a5000ffe5ff +2026-08-10-session-log-version-mechanism.md: 25eb1230a254219c827b1d2750dba367b113f9f7 +2026-08-10-session-log-version-mechanism.zh.md: c47670f2de77773c17c9595eff442bf7f1e8ec3e diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md index 5358edfe15..25eb1230a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -20,7 +20,7 @@ Session logs must be upgradable after release, and the runtime that ships first ## Consequences -What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. +What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md index b790338c87..c47670f2de 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -20,7 +20,7 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决 ## 影响 -v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。 +v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。 ## 曾考虑的替代方案 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 12fe94e64c..fb2d7d3d3a 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: 2b150ba09eea4365fd0559c68d6f9499ae336933 -persistence-catalog.zh.md: 0ca78a63e85705aaba9c9727c22509891670f42d +persistence-catalog.md: 88d8f833ce3e6c51692db74519279a5354a1759b +persistence-catalog.zh.md: 5ab0fa0c6ccb099ba10b9021625f20486a02d94c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 2b150ba09e..88d8f833ce 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -7,7 +7,7 @@ Every event type that can appear in a session's durable event log: the complete This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md). -The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. +The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. ## Event envelope diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 0ca78a63e8..5ab0fa0c6c 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -9,7 +9,7 @@ 英文源文件根据源码生成(`scripts/gen-persistence-catalog.ts`),并由 `pnpm run verify-persistence-catalog`(`doc-sync`(文档同步门禁)的一部分)验证新鲜度;本中文文件作为经评审对侧通过双语配对维护。声明块保留源码声明和嵌套属性的 JSDoc,只移除其所在接口/模块带来的缩进,并使用 `ts persistence-catalog` 围栏(doc-typecheck 会跳过这些围栏,因为声明引用了其所属模块中的类型)。payload 中的类型名称会链接到记录该类型的页面。参见 [persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md)。 -以下信封声明组合了每个事件的 `type`、单调递增的 `seq`、以 epoch 毫秒表示的 `time`、`data`,以及条件字段 `surfaceOp`/`sourceEventSeqs`。**surface** 表示 `SurfaceEventType` 成员:它会生成一条 LLM(大语言模型)消息,并声明该事件如何加入 surface 列表。**log-only** 表示其他所有事件:这类记录可持久化、可回放,但不参与派生历史。每个 payload 均可进行 JSON 序列化(在 `Session.append` 处强制执行),整个格式固定为 `SESSION_FORMAT_VERSION = 0`:这是预发布格式,不暗示任何兼容性(参见[版本立场](subsystems/persistence.md))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。 +以下信封声明组合了每个事件的 `type`、单调递增的 `seq`、以 epoch 毫秒表示的 `time`、`data`、可选的未知类型跳过标记 `ignorable`,以及条件字段 `surfaceOp`/`sourceEventSeqs`。**surface** 表示 `SurfaceEventType` 成员:它会生成一条 LLM(大语言模型)消息,并声明该事件如何加入 surface 列表。**log-only** 表示其他所有事件:这类记录可持久化、可回放,但不参与派生历史。每个 payload 均可进行 JSON 序列化(在 `Session.append` 处强制执行),整个格式固定为 `SESSION_FORMAT_VERSION = 0`:这是预发布格式,不暗示任何兼容性(参见[版本立场](subsystems/persistence.md))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。 ## 事件信封 diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index b500928227..f81e040bda 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: de7c5c4d445986fe306a782683a8559b25677c94 -persistence.zh.md: a52506aa86418f66e6b1a372020cc316f67cc1c7 +persistence.md: 7deaa9b30b5a6b1e3cbdcc38255b3974b5abf477 +persistence.zh.md: c5afcf67319da408b739d41b2b7ad3eb434ffbad diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index de7c5c4d44..7deaa9b30b 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -89,7 +89,7 @@ interface SessionHeader { ## Format refusal — logs a build cannot faithfully read -A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). +A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating today's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). ## `CreateSessionOptions` — seeding and metadata @@ -346,5 +346,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index a52506aa86..c5afcf6731 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -89,7 +89,7 @@ interface SessionHeader { ## 格式拒绝:本构建无法可靠读取的日志 -后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。 +后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于当前 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。 ## `CreateSessionOptions`:seed 与元数据 @@ -346,5 +346,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 809982f94d..2923b9e09b 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -9,8 +9,9 @@ */ import { join } from 'node:path' -import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session' +import { decodeStorageRecord, packChunkRuns, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session' +import { SessionFormatUnsupportedError, sessionFormatVersionRefusal } from '@deepseek-ai/dsh-session-persistence' /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' @@ -229,6 +230,22 @@ interface SessionLogScan { } /** Parse one complete header record supplied independently from event rows. */ +/** + * Refuse a header carrying a format version this build does not read BEFORE + * validating the current header shape or decoding any event row: a future + * format need not satisfy today's structural checks at all, and its user must + * see "upgrade the harness", never "corrupt session log". + * @param parsed - the JSON-parsed first line of a session artifact. + */ +function refuseForeignFormatVersion(parsed: unknown): void { + if (typeof parsed !== 'object' || parsed === null) return + const { version, id } = parsed as { version?: unknown; id?: unknown } + if (typeof version !== 'number' || version === SESSION_FORMAT_VERSION) return + throw new SessionFormatUnsupportedError( + sessionFormatVersionRefusal(typeof id === 'string' ? id : String(id), version), + ) +} + function parseHeaderRecord(record: Buffer): SessionHeader { if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) { throw new Error('empty or header-less session log') @@ -239,6 +256,7 @@ function parseHeaderRecord(record: Buffer): SessionHeader { } catch { throw new Error('corrupt session log: header line is not valid JSON') } + refuseForeignFormatVersion(parsed) if (!isHeaderLine(parsed)) { throw new Error('corrupt session log: first line is not a session header') } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index a3a4e04164..6411a077cb 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -16,7 +16,7 @@ import { scheduler } from 'node:timers/promises' import { randomBytes } from 'node:crypto' import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' @@ -256,19 +256,29 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } let prefix: Omit, 'revision'> - if (this.compression === 'zstd') { - prefix = await this.readZstdPrefix(buffer, signal) - } else { - signal?.throwIfAborted() - const { meta, events, committedBytes } = scanLog(buffer) - signal?.throwIfAborted() - prefix = { - meta, - events, - ...committedBytes < buffer.byteLength - ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } - : {}, + try { + if (this.compression === 'zstd') { + prefix = await this.readZstdPrefix(buffer, signal) + } else { + signal?.throwIfAborted() + const { meta, events, committedBytes } = scanLog(buffer) + signal?.throwIfAborted() + prefix = { + meta, + events, + ...committedBytes < buffer.byteLength + ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } + : {}, + } } + } catch (error: unknown) { + // A parse-time format refusal predates any SessionHeader, so the + // coordinator's locate-based enrichment cannot run; attach the artifact + // this read actually refused. + if (error instanceof SessionFormatUnsupportedError && error.location === undefined) { + throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path }) + } + throw error } signal?.throwIfAborted() await this.assertStoredIdentity(path, prefix.meta, expectedId, signal) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 70781a5d20..733594baf7 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { isAbsolute, join, relative, resolve } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -187,6 +187,25 @@ describe('SessionPersistenceJsonl: format helpers', () => { await fiber.dispose() }) + it('refuses a structurally foreign future header as unsupported, not corrupt', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + // A future format need not satisfy today's header shape at all (no + // createdAt, unknown fields): the version must be refused before shape + // validation, so the user sees the upgrade direction. + const id = SessionId('future-shape') + const path = rawLogPath(resolve(absoluteRoot), '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id, futureOnly: true })}\n{"future":"row"}\n`) + const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) + expect(failure?.message).toContain(`(raw log: ${path})`) + await fiber.dispose() + }) + it('points a format refusal at the raw log path', async () => { const absoluteRoot = await freshRoot() const ctx = new Context() diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index edb755197c..eed71ad212 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: 7e62360ccf47151f5c450685bfebe6e89bbf187b -README.zh.md: 3d819ef0ab4f85c83c2e640f627e36341318ac35 +README.md: 324c00b3202bd136566137e1bd398b29d2ea4b82 +README.zh.md: 2ef5e9a90f0323f8edf8fdc4f936c41ca7e08c70 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 7e62360ccf..324c00b320 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -16,7 +16,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `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. | | `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Unknown-type refusal follows that access pattern: a seek read checks only the returned suffix, while the sequential fallback also refuses on an unknown required event below the window. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 3d819ef0ab..2ef5e9a90f 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -16,7 +16,7 @@ | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | | `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。未知类型拒绝遵循同一读取方式:寻址读取只检查返回的后缀,顺序回退路径还会拒绝窗口以下的未知必需事件。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 6049868f98..eeeb8a5778 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -64,6 +64,22 @@ export class SessionFormatUnsupportedError extends Error { } } +/** + * Direction-aware refusal text for a stored session whose format version this + * build does not read. Shared by the coordinator's load-time check and by + * backends that must refuse BEFORE decoding version-dependent structure (a + * future format may not satisfy today's structural checks at all, and the + * user must see "upgrade the harness", never "corrupt"). + * @param id - the stored session id, for message context. + * @param version - the stored format version. + * @returns the stable refusal text, without a raw-log path suffix. + */ +export function sessionFormatVersionRefusal(id: string, version: number): string { + return version > SESSION_FORMAT_VERSION + ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` + : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` +} + /** Coordinator policy supplied by a concrete persistence backend. */ export interface PersistenceCoordinatorOptions { /** Maximum completed unpublished preparations retained for reuse. */ @@ -147,6 +163,11 @@ export interface PersistenceBackend { * contains a supported legacy shape whose normalization needs earlier * message-identity facts, in which case the coordinator falls back * to the complete stored prefix. + * Unknown-type refusal follows the same suffix scope: a seek-capable + * backend's `readFrom` checks only the returned suffix, while the + * sequential fallback parses the whole artifact and refuses on an unknown + * required event anywhere in it — over-refusal on the sequential side is + * accepted rather than widening the seek read. * @param id - persisted session id to resolve. * @param fromSeq - first event seq to include (non-negative safe integer, * validated by the coordinator before this hook runs). @@ -660,9 +681,13 @@ export class PersistenceCoordinator { private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { // Every append route converges here: the public service, live write-behind - // drains, and HMR seed/suffix adoption. Keep vocabulary rejection at that - // shared boundary so a stale JavaScript plugin cannot persist an event that - // this same backend will refuse to load. + // drains, and HMR seed/suffix adoption. Legacy-shape rejection stays at + // this shared boundary so a stale JavaScript plugin cannot persist a + // retired shape this backend refuses to load. The unknown-type guard is + // deliberately read-side only: an append-time refusal would stall a live + // session's durability mid-flight, which costs more than a loud refusal at + // the log's next load (trade-off owned by the session-log-version-mechanism + // Agent Note). assertSupportedEvents(events, id) if (events.length === 0) return this.preparations.assertWritable(id) @@ -1020,9 +1045,7 @@ export class PersistenceCoordinator { private assertVersion(meta: SessionHeader): void { if (meta.version === SESSION_FORMAT_VERSION) return - throw this.unsupported(meta, meta.version > SESSION_FORMAT_VERSION - ? `session "${meta.id}" uses log format v${meta.version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` - : `session "${meta.id}" uses log format v${meta.version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`) + throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version)) } /** @@ -1283,6 +1306,7 @@ export class PersistenceCoordinator { } this.assertVersion(meta) const storedEvents = snapshotStoredEvents(events, session.header.id) + this.assertEventsSupported(meta, storedEvents) if (!seedCoversPrefix(seed, storedEvents)) { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) } diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 62941477ff..97ae8438f1 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -38,6 +38,7 @@ export { PersistenceCoordinator, SessionFormatUnsupportedError, SessionPersistenceCorruptionError, + sessionFormatVersionRefusal, } from './coordinator.ts' export type { PersistenceBackend, diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index e95f78a99a..debc165eab 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -360,7 +360,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv '', 'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).', '', - 'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.', + 'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.', '', '## Event envelope', '', From 11bfb21e913d341d0dd567f7f343064c76aee969 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 17:07:55 +0800 Subject: [PATCH 66/77] test(session-persistence-jsonl): cover the version guard's non-object and non-string-id paths --- .../tests/jsonl.spec.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 733594baf7..35b3829ed3 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -206,6 +206,40 @@ describe('SessionPersistenceJsonl: format helpers', () => { await fiber.dispose() }) + it('keeps a non-object header line a corruption, not a format refusal', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + // Valid JSON that is no object carries no version to compare, so the + // version guard must pass it through to the corruption diagnostics. + const id = SessionId('scalar-header') + const path = rawLogPath(resolve(absoluteRoot), '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, '42\n') + const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).not.toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain('first line is not a session header') + await fiber.dispose() + }) + + it('names a foreign-version header by its stringified non-string id', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + // A future header's id field is as untrusted as the rest of its shape: + // the refusal must still name the session it read, not crash on the type. + const id = SessionId('numeric-id') + const path = rawLogPath(resolve(absoluteRoot), '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id: 123 })}\n`) + const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain('session "123" uses log format v42') + await fiber.dispose() + }) + it('points a format refusal at the raw log path', async () => { const absoluteRoot = await freshRoot() const ctx = new Context() From 5265fd084d94d9d12f393e484ced2b2490e1433a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 22:28:47 +0800 Subject: [PATCH 67/77] fix(snapshot): use rescoped Cordis package --- examples/headless-agent/tests/session-format-guard.snapshot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/headless-agent/tests/session-format-guard.snapshot.ts b/examples/headless-agent/tests/session-format-guard.snapshot.ts index d7f327b6b6..ac1b5ae43c 100644 --- a/examples/headless-agent/tests/session-format-guard.snapshot.ts +++ b/examples/headless-agent/tests/session-format-guard.snapshot.ts @@ -8,7 +8,7 @@ import { join, dirname } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import SessionStore, { SESSION_FORMAT_VERSION, From d5cab00e4e158b0b4d67c39ca5aeef40bd92f5aa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 11:26:17 +0800 Subject: [PATCH 68/77] docs: add benchmark SDK entry point --- BENCHMARK.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 BENCHMARK.md diff --git a/BENCHMARK.md b/BENCHMARK.md new file mode 100644 index 0000000000..6e8f466a1f --- /dev/null +++ b/BENCHMARK.md @@ -0,0 +1,3 @@ +# Running benchmarks + +To run benchmark tasks with the minimal agent composition, follow [Get started with the Python SDK](docs/user/guide/python-sdk.md). The guide covers installation, running [`minimal.cordis.yml`](examples/jsonrpc-agent/minimal.cordis.yml), and isolating workspaces and session IDs between tasks. From 67ef355800f56fd7b862008595bfee40db710a02 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 11 Aug 2026 11:58:47 +0800 Subject: [PATCH 69/77] refactor(cli): inline source launch script --- ...ce-run-without-managed-installer.i18n.yaml | 4 +- ...10-source-run-without-managed-installer.md | 4 +- ...source-run-without-managed-installer.zh.md | 4 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/tests/source-launch.compat.spec.ts | 85 ++----------------- package.json | 2 +- scripts/run-source-dsh.ts | 48 ----------- 9 files changed, 16 insertions(+), 139 deletions(-) delete mode 100644 scripts/run-source-dsh.ts diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml index 0a1cac0e80..9920c09c11 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.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/simplification/2026-08-10-source-run-without-managed-installer.md -2026-08-10-source-run-without-managed-installer.md: 85b98cabe18f038908a00d789c48197c36b80a3b -2026-08-10-source-run-without-managed-installer.zh.md: 4a322a48c23352cecfeccef3a93d3b782b8d3f80 +2026-08-10-source-run-without-managed-installer.md: ac506b72acab0dd6c92ce6111487d091b2bf4a73 +2026-08-10-source-run-without-managed-installer.zh.md: 86e44a90a9e7b34ad37b6a4bfd3ad14de40868f5 diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md index 85b98cabe1..ac506b72ac 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md @@ -12,7 +12,7 @@ That lifecycle is not required to run or develop DeepSeek Harness from a source ## Decision -The repository supports source execution through its root `pnpm` scripts. The `pnpm dsh ` launcher runs the complete repository build before launching the source CLI. It discards successful build output so CLI stdout remains machine-readable, reports failed build diagnostics on stderr, and sets `NODE_USE_ENV_PROXY=1` for the CLI process. Users select Web with `pnpm dsh web` and headless execution with `pnpm dsh --profile headless "task"`. The independent ACP example remains available through `pnpm run demo:acp`. +The repository supports source execution through its root `pnpm` scripts. The `dsh` entry in `package.json` runs `pnpm run build`, then launches `apps/cli/src/bin.ts` through `node --import tsx/esm`; build output remains visible before the CLI output. The package script forwards arguments and inherits the caller's environment, including `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. Users select Web with `pnpm dsh web` and headless execution with `pnpm dsh --profile headless "task"`. The independent ACP example remains available through `pnpm run demo:acp`. The repository does not distribute a source installer, an installer test suite, or skills that assume a managed `current` symlink and timestamped staging worktrees. Users own source checkout placement, Git updates, and any launcher they create outside the repository. @@ -28,4 +28,4 @@ The repository does not distribute a source installer, an installer test suite, Source users invoke repository scripts rather than an installed `dsh` command. The repository provides no atomic upgrade cutover or preserved staging rollback checkout, and it does not automate the integration or upstream publication of personal source modifications. A future distribution mechanism must justify its ownership of installation and upgrade state, define recovery behavior, and add tests and user documentation without making the source-run path depend on it. Any future publication workflow must isolate one approved feature and obtain explicit approval before its first push and draft PR. -Verification covers repository-wide references to the removed entry points, documentation links, generated third-party-notice freshness, and source CLI smokes that verify the build-first launch and absence of build logs on stdout through `pnpm dsh`. +Verification covers repository-wide references to the removed entry points, documentation links, generated third-party-notice freshness, the build-first `package.json` command, and a source CLI smoke through the exact `node --import tsx/esm` runtime vector. diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md index 4a322a48c2..86e44a90a9 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -仓库通过根目录的 `pnpm` 脚本支持从源码运行。`pnpm dsh ` 启动器会先完成整个仓库的构建,再启动源码 CLI(命令行界面)。它会丢弃成功构建的输出,使 CLI stdout 保持机器可读;在 stderr 中报告构建失败的诊断信息;并为 CLI 进程设置 `NODE_USE_ENV_PROXY=1`。用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh --profile headless "task"` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 +仓库通过根目录的 `pnpm` 脚本支持从源码运行。`package.json` 中的 `dsh` 项先执行 `pnpm run build`,再通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`;构建输出会显示在 CLI(命令行界面)输出之前。该包脚本会转发参数并继承调用方环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,调用方可设置 `NODE_USE_ENV_PROXY=1`。用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh --profile headless "task"` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 仓库不分发源码安装器、安装器测试套件,也不分发依赖受管理的 `current` 符号链接和带时间戳 staging worktree 的 skill。源码检出的存放位置、Git 更新,以及用户在仓库外创建的任何启动器均由用户负责。 @@ -28,4 +28,4 @@ Status: implemented 源码用户通过仓库脚本运行程序,而非使用已安装的 `dsh` 命令。仓库不提供原子升级切换,也不保留 staging 回滚检出;仓库同样不会自动集成个人源码修改或将其发布到上游。未来的分发机制必须说明为何应由其管理安装和升级状态,定义恢复行为,并补充测试与用户文档,同时不得让源码运行路径依赖该机制。未来任何发布工作流都必须隔离出一项获批功能,并在首次推送和创建草稿 PR(Pull Request)前取得明确批准。 -验证范围包括仓库内对已移除入口点的所有引用、文档链接、生成的第三方声明文件的新鲜度,以及通过 `pnpm dsh` 对源码 CLI 进行冒烟测试,验证先构建后启动且 stdout 不含构建日志。 +验证范围包括仓库内对已移除入口点的所有引用、文档链接、生成的第三方声明文件的新鲜度、`package.json` 中的先构建后启动命令,以及通过准确的 `node --import tsx/esm` 运行方式对源码 CLI 进行的冒烟测试。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0393399941..a2cfed084a 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: 48c8e8fd902db4de5ceb5532a67d1559453392c4 -README.zh.md: d51b4256b421ecfb71d294c5b3c25bfcf6ed2c8a +README.md: cef687dc392f97886ea18b162e8f668a44ce2284 +README.zh.md: f6721ec256d404e2b602fb2ed921b41c03633a82 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 48c8e8fd90..cef687dc39 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -81,4 +81,4 @@ Install external plugin bundles through `dsh plugin --profile add `. The script runs the complete repository build, launches `apps/cli/src/bin.ts` with `node --import tsx/esm`, and forwards every argument. Successful build output stays out of CLI stdout, while build failures report diagnostics on stderr. The CLI process receives `NODE_USE_ENV_PROXY=1` so Node versions with environment-proxy fetch support honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. +From the repository root, use `pnpm dsh `. The `package.json` script runs the complete repository build, launches `apps/cli/src/bin.ts` with `node --import tsx/esm`, and forwards every argument. Build output appears before CLI output. The process inherits the launch environment; set `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d51b4256b4..f6721ec256 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -81,4 +81,4 @@ dsh web --help ## 源码执行 -请从仓库根目录使用 `pnpm dsh `。该脚本会完成整个仓库的构建,通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。构建成功时,其输出不会进入 CLI stdout;构建失败时,诊断信息会写入 stderr。CLI 进程会接收 `NODE_USE_ENV_PROXY=1`,从而让支持环境代理的 Node 版本中的 fetch 遵循 `HTTP_PROXY` 和 `HTTPS_PROXY`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 +请从仓库根目录使用 `pnpm dsh `。`package.json` 中的脚本会完成整个仓库的构建,通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。构建输出会显示在 CLI 输出之前。该进程会继承启动环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,请设置 `NODE_USE_ENV_PROXY=1`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index 748ef62c37..597c3cff26 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -1,7 +1,4 @@ -import { existsSync } from 'node:fs' -import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { delimiter, join } from 'node:path' -import { tmpdir } from 'node:os' +import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { execa } from 'execa' import { describe, expect, it } from 'vitest' @@ -19,86 +16,14 @@ import { describe, expect, it } from 'vitest' const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshSourceBin = 'apps/cli/src/bin.ts' -function resolvePnpmExecutable(): string { - const pathKey = Object.keys(process.env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH' - const executableNames = process.platform === 'win32' ? ['pnpm.cmd', 'pnpm.exe', 'pnpm'] : ['pnpm'] - for (const directory of (process.env[pathKey] ?? '').split(delimiter)) { - for (const executableName of executableNames) { - const candidate = join(directory, executableName) - if (existsSync(candidate)) return candidate - } - } - throw new Error('pnpm executable is absent from PATH') -} - -async function createPnpmStub(emitBuildOutput = false): Promise<{ - readonly directory: string - readonly env: NodeJS.ProcessEnv - readonly invocationLog: string -}> { - const directory = await mkdtemp(join(tmpdir(), 'dsh-source-launch-')) - const invocationLog = join(directory, 'pnpm-args.json') - const simulatedOutput = emitBuildOutput - ? "process.stdout.write('tsdown simulated build\\n')\nprocess.stderr.write('build:lib simulated build\\n')\n" - : '' - const stubBody = `const { appendFileSync } = require('node:fs')\nappendFileSync(process.env.DSH_TEST_PNPM_LOG, JSON.stringify(process.argv.slice(2)))\n${simulatedOutput}` - await writeFile(join(directory, 'pnpm'), `#!/usr/bin/env node\n${stubBody}`) - await chmod(join(directory, 'pnpm'), 0o755) - await writeFile(join(directory, 'pnpm-stub.cjs'), stubBody) - await writeFile(join(directory, 'pnpm.cmd'), '@echo off\r\nnode "%~dp0pnpm-stub.cjs" %*\r\n') - const pathKey = Object.keys(process.env).find(key => key.toUpperCase() === 'PATH') ?? 'PATH' - return { - directory, - invocationLog, - env: { - ...process.env, - [pathKey]: `${directory}${delimiter}${process.env[pathKey] ?? ''}`, - DSH_TEST_PNPM_LOG: invocationLog, - }, - } -} - describe('dsh SOURCE launcher (node --import tsx/esm)', () => { - it('runs the repository build before launching the source CLI', async () => { - const stub = await createPnpmStub() - try { - const result = await execa(process.execPath, ['--import', 'tsx/esm', 'scripts/run-source-dsh.ts', '--help'], { - cwd: repoRoot, - env: stub.env, - reject: false, - }) - expect(result.exitCode).toBe(0) - expect(result.stdout).toMatch(/^Usage: dsh /) - await expect(readFile(stub.invocationLog, 'utf8')).resolves.toBe('["run","build"]') - } finally { - await rm(stub.directory, { recursive: true, force: true }) + it('builds before launching the source CLI', async () => { + const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { + readonly scripts?: Record } + expect(rootPackage.scripts?.dsh).toBe('pnpm run build && node --import tsx/esm apps/cli/src/bin.ts') }) - it('builds without mixing build logs into CLI stdout', async () => { - const pnpmExecutable = resolvePnpmExecutable() - const stub = await createPnpmStub(true) - try { - const result = await execa(pnpmExecutable, ['dsh', '--help'], { - cwd: repoRoot, - env: stub.env, - timeout: 25_000, - killSignal: 'SIGKILL', - reject: false, - }) - if (result.timedOut) { - throw new Error(`pnpm dsh --help did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) - } - expect(result.exitCode).toBe(0) - expect(result.stdout).toMatch(/^Usage: dsh /) - expect(result.stdout).not.toContain('tsdown') - expect(result.stdout).not.toContain('build:lib') - await expect(readFile(stub.invocationLog, 'utf8')).resolves.toBe('["run","build"]') - } finally { - await rm(stub.directory, { recursive: true, force: true }) - } - }, 30_000) - it('boots the source entry and requires a profile', async () => { const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], { cwd: repoRoot, diff --git a/package.json b/package.json index d34d3322b2..2e2c798faf 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "release:pack": "tsx scripts/release/pack.ts", "release:verify-packed-install": "tsx scripts/release/verify-packed-install.ts", "release:publish": "tsx scripts/release/publish.ts", - "dsh": "tsx scripts/run-source-dsh.ts", + "dsh": "pnpm run build && node --import tsx/esm apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/scripts/run-source-dsh.ts b/scripts/run-source-dsh.ts deleted file mode 100644 index 3d97a44faa..0000000000 --- a/scripts/run-source-dsh.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Source-checkout launcher. Successful build output stays out of CLI stdout; - * build failures report their captured diagnostics on stderr before exiting. - */ -import process from 'node:process' -import { fileURLToPath } from 'node:url' - -import { execa } from 'execa' - -const repoRoot = fileURLToPath(new URL('..', import.meta.url)) -const sourceBin = fileURLToPath(new URL('../apps/cli/src/bin.ts', import.meta.url)) - -function completeFrom(result: { readonly exitCode?: number; readonly signal?: string }): void { - if (result.signal !== undefined) { - process.kill(process.pid, result.signal) - return - } - process.exitCode = result.exitCode ?? 1 -} - -function reportBuildFailure(result: { - readonly all: string | undefined - readonly shortMessage: string | undefined -}): void { - const diagnostic = result.all === undefined || result.all.length === 0 - ? result.shortMessage ?? 'Source build failed without diagnostics.' - : result.all - process.stderr.write(diagnostic.endsWith('\n') ? diagnostic : `${diagnostic}\n`) -} - -const build = await execa('pnpm', ['run', 'build'], { - all: true, - cwd: repoRoot, - reject: false, - stripFinalNewline: false, -}) -if (build.failed) { - reportBuildFailure(build) - completeFrom(build) -} else { - const cli = await execa(process.execPath, ['--import', 'tsx/esm', sourceBin, ...process.argv.slice(2)], { - cwd: repoRoot, - env: { ...process.env, NODE_USE_ENV_PROXY: '1' }, - reject: false, - stdio: 'inherit', - }) - completeFrom(cli) -} From 58cdf3d7f74e93c1ffbdc710ff9c7d48a18164f0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 12:03:30 +0800 Subject: [PATCH 70/77] fix(client): adopt the renamed client plugin manifest key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master moved the browser-half declaration from a top-level `dshClient` to `dsh.client`, and the new package still carried the old key. The Loader row resolved, the host half applied, and the bundle built — the browser simply never mounted the entry, so the session header rendered no task control at all while `ctx.tasks` held a running task. Nothing reported it; the web e2e is what caught it. Also carried the merged tree onto master's current package rules: the release manifest shape (version, repository, workspace-protocol peers, no `private`) and `@deepseek-ai/cordis` in place of the old `cordis` specifier, plus the connection handle the locale plugin's settings scope now reads in tests. --- packages/client/ui-task/package.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-task/package.json b/packages/client/ui-task/package.json index b5f372cb16..c8354f8f6c 100644 --- a/packages/client/ui-task/package.json +++ b/packages/client/ui-task/package.json @@ -21,14 +21,16 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-primitives" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", From a3e448ee5d58a786d8e29b116a1022506ac44bd1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 11 Aug 2026 12:07:21 +0800 Subject: [PATCH 71/77] docs(fs): document bounded byte reads --- docs/subsystems/filesystem.i18n.yaml | 4 ++-- docs/subsystems/filesystem.md | 4 ++-- docs/subsystems/filesystem.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index a8f0d9965a..73cf951086 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.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/filesystem.md -filesystem.md: b87021630593c85379cbc10af4b0da50ddc7c179 -filesystem.zh.md: 6f4d1441f1bafbe2316003519841a7c9c2c0dbab +filesystem.md: 01fe2d07f5497374019855ca46ced7e173d21445 +filesystem.zh.md: e68246d7a44b8812e132e02979a2c0cda6adb792 diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index b870216305..01fe2d07f5 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -52,7 +52,7 @@ type FsTargetKey = Branded<'FsTargetKey'> type FsVersion = Branded<'FsVersion'> ``` -`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. A protocol consumer that needs a byte ceiling applies it while consuming `streamText`, so the filesystem seam needs no consumer-specific bounded-read primitive. +`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets consumers reject directories and special files before reading, and `size` lets text consumers choose `readText` vs `streamText` without probing by failure. A text consumer applies its own retention ceiling while consuming `streamText`. Raw-byte consumers use `readBytes(target, signal, maxBytes)`; its required complete-content cap makes a known or discovered overflow fail with `FS_TOO_LARGE` instead of truncating or buffering without a bound. ```ts type-equiv /** @@ -275,7 +275,7 @@ type FsErrorCode = ## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures. +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `readBytes`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures. diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 6f4d1441f1..e68246d7a4 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -52,7 +52,7 @@ type FsTargetKey = Branded<'FsTargetKey'> type FsVersion = Branded<'FsVersion'> ``` -`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让工具在读取前拒绝目录或特殊文件;`size` 让工具无需通过失败探测即可选择 `readText` 还是 `streamText`。需要字节上限的协议消费方在消费 `streamText` 时执行该上限,因此文件系统 seam 无需消费方专用的有界读取原语。 +`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让消费方在读取前拒绝目录和特殊文件;`size` 让文本消费方无需通过失败探测即可选择 `readText` 还是 `streamText`。文本消费方在消费 `streamText` 时执行自己的保留量上限。原始字节消费方调用 `readBytes(target, signal, maxBytes)`;其必填的完整内容上限会使已知或读取中发现的超限以 `FS_TOO_LARGE` 失败,不会截断结果或无界缓冲。 ```ts type-equiv /** @@ -275,7 +275,7 @@ type FsErrorCode = ## 服务与插件 -`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 +`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 From d00590c707fcf60427132fdcf18584b1a18f0c81 Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 10 Aug 2026 21:36:20 +0800 Subject: [PATCH 72/77] docs: replace nonexistent repository references --- ...andatory-app-attribution-headers.i18n.yaml | 4 +- ...06-21-mandatory-app-attribution-headers.md | 4 +- ...21-mandatory-app-attribution-headers.zh.md | 4 +- ...13-documentation-site-projection.i18n.yaml | 4 +- ...026-07-13-documentation-site-projection.md | 4 +- ...-07-13-documentation-site-projection.zh.md | 4 +- apps/cli/tests/dsh-badge.snapshot.ts | 8 +-- .../cordis-tutorial/01-first-plugin.i18n.yaml | 4 +- docs/cordis-tutorial/01-first-plugin.md | 2 +- docs/cordis-tutorial/01-first-plugin.zh.md | 2 +- .../02-lifecycle-and-effects.i18n.yaml | 4 +- .../02-lifecycle-and-effects.md | 2 +- .../02-lifecycle-and-effects.zh.md | 2 +- docs/cordis-tutorial/03-services.i18n.yaml | 4 +- docs/cordis-tutorial/03-services.md | 2 +- docs/cordis-tutorial/03-services.zh.md | 2 +- docs/cordis-tutorial/04-events.i18n.yaml | 4 +- docs/cordis-tutorial/04-events.md | 2 +- docs/cordis-tutorial/04-events.zh.md | 2 +- docs/cordis-tutorial/05-config.i18n.yaml | 4 +- docs/cordis-tutorial/05-config.md | 2 +- docs/cordis-tutorial/05-config.zh.md | 2 +- .../06-composition-and-hmr.i18n.yaml | 4 +- .../cordis-tutorial/06-composition-and-hmr.md | 2 +- .../06-composition-and-hmr.zh.md | 2 +- .../07-into-the-harness.i18n.yaml | 4 +- docs/cordis-tutorial/07-into-the-harness.md | 2 +- .../cordis-tutorial/07-into-the-harness.zh.md | 2 +- docs/cordis-tutorial/index.i18n.yaml | 4 +- docs/cordis-tutorial/index.md | 4 +- docs/cordis-tutorial/index.zh.md | 4 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 2 +- docs/subsystems/llm-streaming.zh.md | 2 +- docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 2 +- docs/user/guide/python-sdk.zh.md | 2 +- docs/user/guide/quickstart.i18n.yaml | 4 +- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- examples/mcp-memory/README.i18n.yaml | 4 +- examples/mcp-memory/README.md | 12 ---- examples/mcp-memory/README.zh.md | 12 ---- packages/llm/llm/src/attribution.ts | 5 +- packages/llm/llm/tests/attribution.spec.ts | 4 +- .../skill/skill-badge/assets/dsh-badge.md | 4 +- scripts/project-doc-site.spec.ts | 6 +- scripts/project-doc-site.ts | 4 +- .../verify-public-repository-links.spec.ts | 67 +++++++------------ scripts/verify-public-repository-links.ts | 49 ++++++-------- website/.vitepress/config.ts | 6 +- 51 files changed, 121 insertions(+), 176 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 21c6e32e11..9e32647a60 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.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-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: 12482ca80d19e5cd1e62b8860865db6cccea61ab -2026-06-21-mandatory-app-attribution-headers.zh.md: 43f356887948b88d34ba41d46ed1cd2f1c89a1e2 +2026-06-21-mandatory-app-attribution-headers.md: 39050a53ec76e8c5a6cac4d8e31fa15b992c406e +2026-06-21-mandatory-app-attribution-headers.zh.md: bfd6aa2540022f68cf9f69ccc2c12b3bc0978960 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index 12482ca80d..39050a53ec 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri - product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity) - version: read from the owning package's manifest via `createRequire`, never a hand-copied constant -- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home, which must exist before release +- app URL: `https://github.com/deepseek-ai/deepseek-harness` - the repository home The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override hook is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields. @@ -77,8 +77,6 @@ The landed contract: **Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`. -**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise that blocks release. - **Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract. **OpenRouter rankings do not benefit yet.** `User-Agent` is the correct baseline for provider-neutral HTTP identity, but it will not create OpenRouter app pages or rankings because OpenRouter requires `HTTP-Referer` for that product feature. That is deliberate: public app marketplace participation is a separate product decision, not a prerequisite for mandatory request attribution. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 43f3568879..bfd6aa2540 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -32,7 +32,7 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title` - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 -- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页,且必须在发布前实际存在 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness`——仓库主页 默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖钩子就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 @@ -77,8 +77,6 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title` **提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 -**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个阻塞发布的悬空承诺。 - **不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 约定。 **OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关的 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 来实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。 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..56f184e831 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: 309dbd96c5f1ca87d137cdc3839acfc5c5aa22f2 +2026-07-13-documentation-site-projection.zh.md: b01965fb1b46ab618b5b8bcb748bb5a8940d6e69 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..309dbd96c5 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 @@ -18,9 +18,9 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g 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. -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. +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 `deepseek-ai/deepseek-harness` repository 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. -`verify-public-repository-links` rejects internal repository remotes from tracked files. Public source links use the public home, while work tracking stays in repository metadata and source carries a TODO only when the local boundary matters to maintainers. +`verify-public-repository-links` rejects references to the unavailable legacy repository from tracked files. Source and edit links use the current repository home. `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. 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..b01965fb1b 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 @@ -18,9 +18,9 @@ Status: implemented 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成公开 `deepseek-ai/deepseek-harness-sdk` 主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 `deepseek-ai/deepseek-harness` 仓库主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 -`verify-public-repository-links` 会拒绝已跟踪文件中的内部仓库远程链接。公开源文件链接使用公开主页,而工作跟踪留在仓库元数据中;只有本地边界对维护者有意义时,源文件才保留 TODO。 +`verify-public-repository-links` 会拒绝已跟踪文件中指向不可用旧仓库的引用。源文件链接和编辑链接使用当前仓库主页。 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 diff --git a/apps/cli/tests/dsh-badge.snapshot.ts b/apps/cli/tests/dsh-badge.snapshot.ts index 00d5278f82..28e418d6fc 100644 --- a/apps/cli/tests/dsh-badge.snapshot.ts +++ b/apps/cli/tests/dsh-badge.snapshot.ts @@ -85,14 +85,14 @@ describe('dsh badge assembled snapshot', () => { - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` - - Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\` + - Project URL: \`https://github.com/deepseek-ai/deepseek-harness\` ## Markdown Use this linked badge in Markdown: \`\`\`markdown - [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) \`\`\` If attribution should not be linked, use: @@ -124,14 +124,14 @@ describe('dsh badge assembled snapshot', () => { - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` - - Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\` + - Project URL: \`https://github.com/deepseek-ai/deepseek-harness\` ## Markdown Use this linked badge in Markdown: \`\`\`markdown - [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) \`\`\` If attribution should not be linked, use: diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 2ac502e501..cbb4aee438 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.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/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: 448034b54107d5620673052ad388feecc22fe1e1 -01-first-plugin.zh.md: a1838be61f8099831d59afa266fe40ce6bd165bf +01-first-plugin.md: 79df2f42df1f34a7ef32cc81607e1b926a1854ae +01-first-plugin.zh.md: 87cd7a96843d69d68fb95662e59bbfb9ebfc2a37 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 448034b541..79df2f42df 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -92,4 +92,4 @@ One caveat worth knowing early: a config entry whose module cannot be **resolved Next: [Lifecycle and effects](02-lifecycle-and-effects.md) — what happens when a plugin unloads. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index a1838be61f..87cd7a9684 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -92,4 +92,4 @@ export function apply(ctx: Context) { 下一章:[生命周期与 effect](02-lifecycle-and-effects.md):插件卸载时会发生什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml index f97516c457..26ffcac268 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.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/cordis-tutorial/02-lifecycle-and-effects.md -02-lifecycle-and-effects.md: 3e88c6f1e1fb1bc825fb74434520993c855010c3 -02-lifecycle-and-effects.zh.md: 3cceecfb8334f2ed2ec9942fa876a0e51e1b315f +02-lifecycle-and-effects.md: 8e75708eb0cba1aceb7fa3dae5ae334995eb113f +02-lifecycle-and-effects.zh.md: bc4489e702ab58b5d1efd8a72fc693ee48af2624 diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md index 3e88c6f1e1..8e75708eb0 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -95,4 +95,4 @@ One ordering caveat: disposers start in reverse registration order, but multiple Next: [Services](03-services.md) — how plugins share capabilities. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md index 3cceecfb83..bc4489e702 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md @@ -95,4 +95,4 @@ PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 下一章:[服务](03-services.md):插件如何共享功能。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml index 0d5f9dfccb..372e028a47 100644 --- a/docs/cordis-tutorial/03-services.i18n.yaml +++ b/docs/cordis-tutorial/03-services.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/cordis-tutorial/03-services.md -03-services.md: 3f2273ba4061912640e409d7b4deb4cd1b45684f -03-services.zh.md: 657271aba6c0b2e824c79fa822e29c8a6dcf4275 +03-services.md: 32007284be99ef46b4621089c9b3a80317e77189 +03-services.zh.md: d82be29aa69686b8dc10cc6a45a658683c017cbd diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md index 3f2273ba40..32007284be 100644 --- a/docs/cordis-tutorial/03-services.md +++ b/docs/cordis-tutorial/03-services.md @@ -95,4 +95,4 @@ Service names live in one flat namespace per application. Prefix or namespace yo Next: [Events](04-events.md) — communication without a shared service. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md index 657271aba6..d82be29aa6 100644 --- a/docs/cordis-tutorial/03-services.zh.md +++ b/docs/cordis-tutorial/03-services.zh.md @@ -95,4 +95,4 @@ export function apply(ctx: Context) { 下一章:[事件](04-events.md):无需共享服务即可通信。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml index 0f86242ecd..4ce3fb9a65 100644 --- a/docs/cordis-tutorial/04-events.i18n.yaml +++ b/docs/cordis-tutorial/04-events.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/cordis-tutorial/04-events.md -04-events.md: 0302adf7c81b802b406f5f6737539ccf3eb970f9 -04-events.zh.md: ae41e757c06a46ef70c250c94a124546769e0112 +04-events.md: db911b9a06d7304d73030a2020de3d465fde2f9c +04-events.zh.md: e0357b2c04785d52a54f839d1b8ff3ecccb027ed diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md index 0302adf7c8..db911b9a06 100644 --- a/docs/cordis-tutorial/04-events.md +++ b/docs/cordis-tutorial/04-events.md @@ -141,4 +141,4 @@ The harness uses waterfalls for decisions that cooperating plugins may wrap or a Next: [Configuration](05-config.md) — plugin options from `cordis.yml`. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md index ae41e757c0..e0357b2c04 100644 --- a/docs/cordis-tutorial/04-events.zh.md +++ b/docs/cordis-tutorial/04-events.zh.md @@ -141,4 +141,4 @@ harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`ag 下一章:[配置](05-config.md):来自 `cordis.yml` 的插件选项。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index 08493c67af..4e953918dd 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-config.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/cordis-tutorial/05-config.md -05-config.md: ad73a732242e4015b2976e6fb193ff464e148dfa -05-config.zh.md: edb3c4113288dfa80e899fb2e5eb21a67d929ca6 +05-config.md: 2357f663135d6fc78a65f9d0952e0bc3f5eefae4 +05-config.zh.md: fbd94d179494ad0b6f73baff2ca525c786cc9e33 diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index ad73a73224..2357f66313 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -81,4 +81,4 @@ The loader used in this repo supports a `!!js` tag for config values that must b Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index edb3c41132..fbd94d1794 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -81,4 +81,4 @@ ValidationError: invalid config: 下一章:[组合与 HMR(热模块替换)](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml index a75d53e7dd..6a79850803 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml +++ b/docs/cordis-tutorial/06-composition-and-hmr.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/cordis-tutorial/06-composition-and-hmr.md -06-composition-and-hmr.md: 490e3de3a98dd823190deefd47e1b6f2f8ba71b5 -06-composition-and-hmr.zh.md: 4aefb5ecd036929590ab373a2883d90779997b4b +06-composition-and-hmr.md: 87ea26014657ae8c8199e1ebb486556c827d96ca +06-composition-and-hmr.zh.md: 830f55de7c1be351fe701cb068197543602619a7 diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md index 490e3de3a9..87ea260146 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -110,4 +110,4 @@ needs-timer is PENDING — a required service is missing Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md index 4aefb5ecd0..830f55de7c 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.zh.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md @@ -110,4 +110,4 @@ needs-timer is PENDING — a required service is missing 下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index cc42357525..fbc6ef1cde 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.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/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: 41a30f032ac02d8e9e3b17ac8d9cd13e9973e36a -07-into-the-harness.zh.md: 159cede00d453796f6a2cacae184229d71e17f87 +07-into-the-harness.md: 2d3c23f9f7f7fc6bd6cabd4e7e68ebfc46e20665 +07-into-the-harness.zh.md: 45dc0ee6f07ab3b0275499cd4ab0c436eebdddef diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 41a30f032a..2d3c23f9f7 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -104,4 +104,4 @@ Where to go next: - The generated `cordis-surface` regions on the [subsystem pages](../subsystems/core.md) — everything you can inject and listen to, each on its owning page. - [Architecture](../architecture.md) — the system map these plugins live in. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 159cede00d..45dc0ee6f0 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -104,4 +104,4 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 - [子系统页面](../subsystems/core.md)上生成的 `cordis-surface` 区块:可以注入和监听的所有内容,各在其所属页面上。 - [架构](../architecture.md):这些插件所处的系统地图。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 234d911a10..06857ab177 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/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/cordis-tutorial/index.md -index.md: cf61fec07acd2022591cd858ba4146b6a4ae1a3d -index.zh.md: 775bf2fce2138b9edd91f5ee46b93b8c4eb4559e +index.md: fb700344e6d07d3864655009d2edac15ee9eede8 +index.zh.md: a68e931d81e745164d8f9a5dc7ec9aec4cd0e590 diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index cf61fec07a..fb700344e6 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -13,7 +13,7 @@ If you want the condensed concept reference instead of a walkthrough, read the [ You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly. ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ The examples use three TypeScript features beyond ordinary modern JavaScript: Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema` to say which object fields a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index 775bf2fce2..a68e931d81 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -13,7 +13,7 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ node --import tsx ../../vendor/cordis/bin.js 第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 校验哪些对象字段。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 052ec4fedd..e1afba5d64 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.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/llm-streaming.md -llm-streaming.md: 8ae4e8b376b4c4221e6179eb719fcf162f3031e4 -llm-streaming.zh.md: 7d244ab882521a90217873fc3cdee12cd5232db8 +llm-streaming.md: 17f984166906914c49b2c330bdf57b3cecdbd013 +llm-streaming.zh.md: 6519710dad8a174418bcc97f1bc4b36296ab969e diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 8ae4e8b376..17f9841669 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -234,7 +234,7 @@ interface AppIdentity { product: string /** Product version; sourced from package metadata, never hand-copied. */ version: string - /** Public home URL of the app, used as the `User-Agent` comment. */ + /** Repository home URL of the app, used as the `User-Agent` comment. */ url: string } ``` diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 7d244ab882..6519710dad 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -238,7 +238,7 @@ interface AppIdentity { product: string /** Product version; sourced from package metadata, never hand-copied. */ version: string - /** Public home URL of the app, used as the `User-Agent` comment. */ + /** Repository home URL of the app, used as the `User-Agent` comment. */ url: string } ``` diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 04cfa163e7..eaf2543f90 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/guide/python-sdk.md -python-sdk.md: c48bc95c9334cfd16a925d12726c20b2f968c753 -python-sdk.zh.md: dc31c391a180a742c7dc10807f6ed2ef8d11927d +python-sdk.md: 0713de6f96c110624d5f5a8f2ccf56dd4ce01096 +python-sdk.zh.md: f8fa877b407a43c643fda405e1059fb487182761 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index c48bc95c93..0713de6f96 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -30,7 +30,7 @@ python -m pip install deepseek-harness-sdk A source build additionally requires Git, Node.js ^22.19 or >= 24, Corepack-enabled pnpm 11, and `uv`. The following commands build the runtime for the current supported host platform, build both wheels, and install them into the active virtual environment: ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness +git clone https://github.com/deepseek-ai/deepseek-harness.git deepseek-harness cd deepseek-harness python -m pip install uv==0.11.23 corepack enable diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index dc31c391a1..f8fa877b40 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -30,7 +30,7 @@ python -m pip install deepseek-harness-sdk 从源码构建还需要 Git、Node.js ^22.19 或 >= 24、通过 Corepack 启用的 pnpm 11,以及 `uv`。以下命令为当前受支持的宿主平台构建运行时和两个 wheel 包,并将它们安装进当前虚拟环境: ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness +git clone https://github.com/deepseek-ai/deepseek-harness.git deepseek-harness cd deepseek-harness python -m pip install uv==0.11.23 corepack enable diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 68d0156d3a..0cca002d4f 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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/guide/quickstart.md -quickstart.md: 635c0dffd6119f95c1951a441691f87f04396b4a -quickstart.zh.md: d0921382bcb0844fa0260c51546622c582867e68 +quickstart.md: e93e5a430f0cb345728581cd6fa3175ffd20b7d1 +quickstart.zh.md: 69cde830bb802ef19cc1204685395b957a0e02e3 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 635c0dffd6..e93e5a430f 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -19,7 +19,7 @@ pnpm -v ## Step 1: install and configure the API key ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install ``` diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index d0921382bc..69cde830bb 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -19,7 +19,7 @@ pnpm -v ## 第一步:安装并配置 API 密钥 ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install ``` diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index d1de74a676..9eaba5254b 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/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 examples/mcp-memory/README.md -README.md: 58da672030eaf2ddf70ee92d506de300efcd9650 -README.zh.md: 0a2f109f9458ec7e1aba50e7fc9b6fd0fca15dbd +README.md: 7e7de76f4123481b78898b8d62228e4821f3ebc9 +README.zh.md: 3473af862011725844ede95dc9460dd91534b484 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 58da672030..7e7de76f41 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -30,18 +30,6 @@ dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--patch` keeps all three disabled. -Without a repository checkout, download the selected overlay directly: - -```sh -mkdir -p "${DSH_HOME:-$HOME/.dsh}" -curl --fail --location \ - --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml -dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" -``` - -Replace `memorix.cordis.yml` in the URL with either of the other filenames to select it. Review a downloaded overlay before running it: Cordis configuration can contain executable `!!js` expressions. - To keep the selection across runs, merge the chosen file's single `insert` patch into a user patch layer — `$DSH_HOME/profiles//cordis.patch.yml` for one profile, or `$DSH_HOME/cordis.patch.yml` for every profile on the machine. Do not copy over an existing file: it may already contain unrelated user patches. ## Provider setup diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 0a2f109f94..3473af8620 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -30,18 +30,6 @@ dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" 请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--patch` 就会让这三项全部保持关闭。 -如果本地没有仓库 checkout,可直接下载所选 overlay: - -```sh -mkdir -p "${DSH_HOME:-$HOME/.dsh}" -curl --fail --location \ - --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml -dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" -``` - -若要选择另外任一配置,请将 URL 中的 `memorix.cordis.yml` 替换为对应文件名。运行下载的 overlay 前,请先审阅其内容:Cordis 配置可以包含可执行的 `!!js` 表达式。 - 如果要跨次运行保留所选配置,请将对应文件中的单个 `insert` patch 合并到用户 patch 层:只对一个 profile 生效则写入 `$DSH_HOME/profiles//cordis.patch.yml`,对本机所有 profile 生效则写入 `$DSH_HOME/cordis.patch.yml`。不要覆盖已有文件,其中可能已经包含无关的用户 patch。 ## 提供方设置 diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index 79cef011de..42980939cc 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -27,7 +27,7 @@ export interface AppIdentity { product: string /** Product version; sourced from package metadata, never hand-copied. */ version: string - /** Public home URL of the app, used as the `User-Agent` comment. */ + /** Repository home URL of the app, used as the `User-Agent` comment. */ url: string } @@ -40,8 +40,7 @@ export interface AppIdentity { export const APP_IDENTITY: AppIdentity = { product: 'deepseek-harness', version, - // TODO(public-home): Ensure this public source repository exists before release. - url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', + url: 'https://github.com/deepseek-ai/deepseek-harness', } /** diff --git a/packages/llm/llm/tests/attribution.spec.ts b/packages/llm/llm/tests/attribution.spec.ts index db74773314..cfe84051dc 100644 --- a/packages/llm/llm/tests/attribution.spec.ts +++ b/packages/llm/llm/tests/attribution.spec.ts @@ -21,7 +21,7 @@ describe('APP_IDENTITY', () => { expect(APP_IDENTITY).toEqual({ product: 'deepseek-harness', version: manifest.version, - url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', + url: 'https://github.com/deepseek-ai/deepseek-harness', }) }) }) @@ -29,7 +29,7 @@ describe('APP_IDENTITY', () => { describe('userAgent', () => { it('renders product/version with the +url comment', () => { expect(userAgent()).toBe( - `deepseek-harness/${manifest.version} (+https://github.com/deepseek-ai/deepseek-harness-sdk)`, + `deepseek-harness/${manifest.version} (+https://github.com/deepseek-ai/deepseek-harness)`, ) }) diff --git a/packages/skill/skill-badge/assets/dsh-badge.md b/packages/skill/skill-badge/assets/dsh-badge.md index 5a789fac2f..9f15ed4d54 100644 --- a/packages/skill/skill-badge/assets/dsh-badge.md +++ b/packages/skill/skill-badge/assets/dsh-badge.md @@ -6,14 +6,14 @@ Add the official “powered by dsh” badge without recreating or restyling it. - Local PNG: [`dsh-badge.png`](dsh-badge.png), 726×120 source image; render at 121×20 - Shields.io image URL: `https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white` -- Project URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` +- Project URL: `https://github.com/deepseek-ai/deepseek-harness` ## Markdown Use this linked badge in Markdown: ```markdown -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) ``` If attribution should not be linked, use: diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index a8f339cec7..9f29abda4c 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -104,7 +104,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[B](./reference/b.md#part) ' - + '[source](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/packages/tool.ts#L2) ' + + '[source](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/packages/tool.ts#L2) ' + '[web](https://example.com)\n', ) }) @@ -130,7 +130,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/abc123/packages/logo.svg)\n') + })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/abc123/packages/logo.svg)\n') }) it('hands an image to the placer and uses the URL it returns', () => { @@ -209,7 +209,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[title](./reference/b.md "b.md") ' - + '[escaped](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/docs/x(y).md)\n', + + '[escaped](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/docs/x(y).md)\n', ) }) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 55c7204d78..e7acc73998 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -15,7 +15,7 @@ import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts' -const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness-sdk' +const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness' const root = resolve(import.meta.dirname, '..') const generatedRoot = resolve(root, 'website/.generated') @@ -209,7 +209,7 @@ function githubTarget( image: boolean, ): string { const path = repoPath(absPath, repoRoot) - if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/${repositoryRef}/${path}${suffix}` + if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/${repositoryRef}/${path}${suffix}` const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob' const lineSuffix = line === undefined ? suffix : `#L${line}` return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}` diff --git a/scripts/verify-public-repository-links.spec.ts b/scripts/verify-public-repository-links.spec.ts index 372018d7cf..4ff7d5bc62 100644 --- a/scripts/verify-public-repository-links.spec.ts +++ b/scripts/verify-public-repository-links.spec.ts @@ -1,60 +1,45 @@ import { describe, expect, it } from 'vitest' -import { findInternalRepositoryReferences } from './verify-public-repository-links.ts' +import { findUnavailableRepositoryReferences } from './verify-public-repository-links.ts' -describe('public repository link policy', () => { - it('rejects encoded and case-varied internal identities without blocking public repositories', () => { - const internalOwner = ['deepseek', 'harness'].join('-') - const internalRepository = [internalOwner, internalOwner].join('/') - const encodedRepository = internalRepository.replaceAll('-', '%2D').replace('/', '%2F') - const htmlEncodedRepository = internalRepository.replace('/', '/') - const jsonEscapedRepository = internalRepository.replace('/', '\\/') - const unicodeEscapedRepository = internalRepository.replace('/', String.raw`\u002f`) +describe('repository link policy', () => { + it('rejects encoded and case-varied references to the unavailable repository', () => { + const unavailableOwner = ['deepseek', 'ai'].join('-') + const unavailableName = ['deepseek', 'harness', 'sdk'].join('-') + const unavailableRepository = `${unavailableOwner}/${unavailableName}` + const encodedRepository = unavailableRepository.replaceAll('-', '%2D').replace('/', '%2F') + const htmlEncodedRepository = unavailableRepository.replace('/', '/') + const jsonEscapedRepository = unavailableRepository.replace('/', '\\/') + const unicodeEscapedRepository = unavailableRepository.replace('/', String.raw`\u002f`) const source = [ - 'https://github.com/deepseek-ai/deepseek-harness-sdk', - `https://github.com/${internalOwner}/cordis`, - `https://github.com/${internalRepository.toUpperCase()}/issues/1`, + 'https://github.com/deepseek-ai/deepseek-harness', + `https://github.com/${unavailableRepository.toUpperCase()}/issues/1`, `https://github.com/${encodedRepository}/issues/2`, `https://github.com/${htmlEncodedRepository}/issues/3`, `"https:\\/\\/github.com\\/${jsonEscapedRepository}\\/issues\\/4"`, `"https:\\/\\/github.com\\/${unicodeEscapedRepository}\\/issues\\/5"`, - `${internalOwner.toUpperCase()}#6`, + `https://github.com/${unavailableOwner}/cordis`, + `https://github.com/example/${unavailableName}`, ].join('\n') - expect(findInternalRepositoryReferences('subject.md', source)).toEqual([ + expect(findUnavailableRepositoryReferences('subject.md', source)).toEqual([ + { file: 'subject.md', line: 2 }, { file: 'subject.md', line: 3 }, { file: 'subject.md', line: 4 }, { file: 'subject.md', line: 5 }, { file: 'subject.md', line: 6 }, - { file: 'subject.md', line: 7 }, - { file: 'subject.md', line: 8 }, ]) }) - it('allows only the exact audited trusted-publishing repository declarations', () => { - const internalOwner = ['deepseek', 'harness'].join('-') - const internalRepository = [internalOwner, internalOwner].join('/') - const repositoryUrl = `git+https://github.com/${internalRepository}.git` - const manifestLine = ` "url": "${repositoryUrl}",` - const constraintLine = `const repositoryUrl = '${repositoryUrl}'` - const allowedDeclarations = [ - ['native/landlock-run/packages/entry/package.json', manifestLine], - ['native/landlock-run/packages/linux-arm64/package.json', manifestLine], - ['native/landlock-run/packages/linux-x64/package.json', manifestLine], - ['scripts/check-workspace-constraints.ts', constraintLine], - ] as const + it('preserves frozen archived Agent Notes', () => { + const unavailableRepository = ['deepseek-ai', 'deepseek-harness-sdk'].join('/') - for (const [file, source] of allowedDeclarations) { - expect(findInternalRepositoryReferences(file, source)).toEqual([]) - } - - const wrongFile = 'native/landlock-run/package.json' - expect(findInternalRepositoryReferences(wrongFile, manifestLine)).toEqual([{ file: wrongFile, line: 1 }]) - - const manifestFile = 'native/landlock-run/packages/entry/package.json' - const wrongField = ` "homepage": "${repositoryUrl}",` - expect(findInternalRepositoryReferences(manifestFile, wrongField)).toEqual([{ file: manifestFile, line: 1 }]) - - const encodedLine = manifestLine.replace('github.com/', 'github.com\\/') - expect(findInternalRepositoryReferences(manifestFile, encodedLine)).toEqual([{ file: manifestFile, line: 1 }]) + expect(findUnavailableRepositoryReferences( + '.agents/notes/archived/process/historical-record.md', + `https://github.com/${unavailableRepository}`, + )).toEqual([]) + expect(findUnavailableRepositoryReferences( + '.agents/notes/implemented/process/active-record.md', + `https://github.com/${unavailableRepository}`, + )).toEqual([{ file: '.agents/notes/implemented/process/active-record.md', line: 1 }]) }) }) diff --git a/scripts/verify-public-repository-links.ts b/scripts/verify-public-repository-links.ts index e612e6641f..a336b725fc 100644 --- a/scripts/verify-public-repository-links.ts +++ b/scripts/verify-public-repository-links.ts @@ -1,4 +1,4 @@ -/** Reject tracked files that expose the internal repository identity outside audited publishing declarations. */ +/** Reject tracked files that reference an unavailable legacy repository. */ import { execFileSync } from 'node:child_process' import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs' @@ -6,22 +6,13 @@ import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' const root = resolve(import.meta.dirname, '..') -const internalOwner = ['deepseek', 'harness'].join('-') -const internalRepository = [internalOwner, internalOwner].join('/') -const internalIssueShorthand = `${internalOwner}#` -const trustedPublishingRepositoryUrl = `git+https://github.com/${internalRepository}.git` - -/** Exact declarations that intentionally expose the source repository for trusted publishing. */ -const allowedInternalRepositoryLineByFile: Readonly> = { - 'native/landlock-run/packages/entry/package.json': `"url": "${trustedPublishingRepositoryUrl}",`, - 'native/landlock-run/packages/linux-arm64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`, - 'native/landlock-run/packages/linux-x64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`, - 'scripts/check-workspace-constraints.ts': `const repositoryUrl = '${trustedPublishingRepositoryUrl}'`, -} +const unavailableOwner = ['deepseek', 'ai'].join('-') +const unavailableRepositoryName = ['deepseek', 'harness', 'sdk'].join('-') +const unavailableRepository = `${unavailableOwner}/${unavailableRepositoryName}` +const archivedAgentNotePrefix = '.agents/notes/archived/' const namedReferenceCharacters: Readonly> = { hyphen: '-', - num: '#', sol: '/', } @@ -40,8 +31,8 @@ function canonicalReferenceText(source: string): string { .toLowerCase() } -/** One tracked reference to the internal repository. */ -export interface InternalRepositoryReference { +/** One tracked reference to the unavailable repository. */ +export interface UnavailableRepositoryReference { /** Repository-relative file path. */ file: string /** One-based source line. */ @@ -49,20 +40,18 @@ export interface InternalRepositoryReference { } /** - * Locate unaudited internal-repository references in one text file. + * Locate unavailable-repository references in one active text file. * @param file - Repository-relative path used in diagnostics. * @param source - Text to inspect. - * @returns every matching source line. + * @returns every matching source line, excluding frozen archived Agent Notes. */ -export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] { - const references: InternalRepositoryReference[] = [] +export function findUnavailableRepositoryReferences(file: string, source: string): UnavailableRepositoryReference[] { + if (file.startsWith(archivedAgentNotePrefix)) return [] + + const references: UnavailableRepositoryReference[] = [] for (const [index, line] of source.split('\n').entries()) { const canonicalLine = canonicalReferenceText(line) - const isAllowedPublishingDeclaration = line.trim() === allowedInternalRepositoryLineByFile[file] - if (!isAllowedPublishingDeclaration - && (canonicalLine.includes(internalRepository) || canonicalLine.includes(internalIssueShorthand))) { - references.push({ file, line: index + 1 }) - } + if (canonicalLine.includes(unavailableRepository)) references.push({ file, line: index + 1 }) } return references } @@ -73,8 +62,8 @@ function trackedFiles(repoRoot: string): string[] { .filter(file => file !== '') } -function scanRepository(repoRoot: string): InternalRepositoryReference[] { - const references: InternalRepositoryReference[] = [] +function scanRepository(repoRoot: string): UnavailableRepositoryReference[] { + const references: UnavailableRepositoryReference[] = [] for (const file of trackedFiles(repoRoot)) { const path = resolve(repoRoot, file) if (!existsSync(path)) continue @@ -82,7 +71,7 @@ function scanRepository(repoRoot: string): InternalRepositoryReference[] { if (!stat.isFile() && !stat.isSymbolicLink()) continue const source = stat.isSymbolicLink() ? readlinkSync(path) : readFileSync(path, 'utf8') if (source.includes('\0')) continue - references.push(...findInternalRepositoryReferences(file, source)) + references.push(...findUnavailableRepositoryReferences(file, source)) } return references } @@ -92,9 +81,9 @@ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(re if (isMain) { const references = scanRepository(root) if (references.length === 0) { - console.log('verify-public-repository-links: tracked files expose no unexpected internal repository identity.') + console.log('verify-public-repository-links: tracked files reference no unavailable repository.') } else { - console.error('verify-public-repository-links: unexpected internal repository references found:') + console.error('verify-public-repository-links: unavailable repository references found:') for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`) process.exitCode = 1 } diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 4dc1a5774b..4cef3f0a67 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -94,14 +94,14 @@ const sharedTheme: Pick { const data: unknown = frontmatter const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') - return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` + return `https://github.com/deepseek-ai/deepseek-harness/edit/master/${editSource}` }, text: '在 GitHub 上编辑此页', }, @@ -161,7 +161,7 @@ export default withMermaid({ const data: unknown = frontmatter const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') - return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` + return `https://github.com/deepseek-ai/deepseek-harness/edit/master/${editSource}` }, text: 'Edit this page on GitHub', }, From 3cffc777195813f49ded29b3efcefe35cbf1a761 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:28:38 -0700 Subject: [PATCH 73/77] feat(feedback): add durable message feedback backend --- ...6-08-10-message-feedback-sidecar.i18n.yaml | 6 + .../2026-08-10-message-feedback-sidecar.md | 43 ++ .../2026-08-10-message-feedback-sidecar.zh.md | 43 ++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 1 + docs/architecture.zh.md | 1 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 13 +- docs/capability-seams.zh.md | 13 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 14 + docs/config-catalog.zh.md | 14 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 9 + docs/module-graph.zh.md | 9 + docs/subsystems/README.i18n.yaml | 4 +- docs/subsystems/README.md | 1 + docs/subsystems/README.zh.md | 1 + docs/subsystems/feedback.i18n.yaml | 6 + docs/subsystems/feedback.md | 76 +++ docs/subsystems/feedback.zh.md | 76 +++ packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 1 + packages/feedback/README.i18n.yaml | 4 +- packages/feedback/README.md | 7 +- packages/feedback/README.zh.md | 7 +- .../message-feedback/README.i18n.yaml | 6 + packages/feedback/message-feedback/README.md | 81 +++ .../feedback/message-feedback/README.zh.md | 81 +++ .../feedback/message-feedback/package.json | 82 +++ .../feedback/message-feedback/src/index.ts | 380 ++++++++++++ .../message-feedback/src/invariant.ts | 27 + .../feedback/message-feedback/src/spec.ts | 88 +++ .../feedback/message-feedback/src/types.ts | 151 +++++ .../message-feedback/tests/helpers.ts | 206 +++++++ .../tests/loader-composition.spec.ts | 115 ++++ .../tests/message-feedback.spec.ts | 546 ++++++++++++++++++ .../feedback/message-feedback/tsconfig.json | 45 ++ .../tool-cordis/src/api-catalog.ts | 94 +++ pnpm-lock.yaml | 52 ++ scripts/gen-cordis-catalog.ts | 20 + scripts/gen-doc-graphs.ts | 13 +- tsconfig.host.json | 1 + 43 files changed, 2333 insertions(+), 25 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md create mode 100644 docs/subsystems/feedback.i18n.yaml create mode 100644 docs/subsystems/feedback.md create mode 100644 docs/subsystems/feedback.zh.md create mode 100644 packages/feedback/message-feedback/README.i18n.yaml create mode 100644 packages/feedback/message-feedback/README.md create mode 100644 packages/feedback/message-feedback/README.zh.md create mode 100644 packages/feedback/message-feedback/package.json create mode 100644 packages/feedback/message-feedback/src/index.ts create mode 100644 packages/feedback/message-feedback/src/invariant.ts create mode 100644 packages/feedback/message-feedback/src/spec.ts create mode 100644 packages/feedback/message-feedback/src/types.ts create mode 100644 packages/feedback/message-feedback/tests/helpers.ts create mode 100644 packages/feedback/message-feedback/tests/loader-composition.spec.ts create mode 100644 packages/feedback/message-feedback/tests/message-feedback.spec.ts create mode 100644 packages/feedback/message-feedback/tsconfig.json diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml new file mode 100644 index 0000000000..25457df431 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.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-10-message-feedback-sidecar.md +2026-08-10-message-feedback-sidecar.md: 2c6fe72174e4683793980727da672948793eb45f +2026-08-10-message-feedback-sidecar.zh.md: ee67d0d694e0502f4f4770484d9505eaa43ad232 diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md new file mode 100644 index 0000000000..2c6fe72174 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md @@ -0,0 +1,43 @@ +# Agent Note: Lifecycle-bound message feedback sidecar + +Status: implemented + +English | [中文](2026-08-10-message-feedback-sidecar.zh.md) + +## Problem + +The existing `/feedback` command records an immutable Session-level `feedback/record` event. That event can release a pending telemetry prefix under `FEEDBACK_ONLY`, so it is the wrong authority for an editable positive/negative rating and optional note attached to one assistant message. Message feedback needs independent update and delete semantics without entering the canonical Session log, changing a projection, reaching the model surface, or implicitly consenting to telemetry. + +A sidecar keyed only by `SessionId` can outlive the log lifecycle it describes when an id is recreated with a different header identity. A Session-wide revision also makes unrelated message edits conflict, while plain storage-domain read/put has no cross-process compare-and-swap. Session disposal is only live-store detach, not durable deletion, and the current Session persistence seam exposes no deletion operation that could own a truthful cascade. + +## Decision + +`@deepseek-ai/dsh-message-feedback` owns the `ctx.messageFeedback` service and stores message feedback as one storage-domain sidecar row per Session. The sidecar is neither Session-log content nor a Session projection. It emits no `feedback/record` event and performs no telemetry handoff; the command-feedback and message-feedback contracts remain independent. + +Every usable row is bound to the inspected Session header identity `{createdAt, cwd}`, not merely its `SessionId`. A lifecycle mismatch is treated as absence: `list` returns no items, and `put` may replace the stale row with one bound to the current identity. An id reused with a different header identity therefore cannot inherit stale feedback. A fork receives its own Session identity and no sidecar copy: even when the fork seed contains the same assistant messages, feedback remains attached to the Session in which the human recorded it. + +`put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization. + +Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold Session is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or cold physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references. + +Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. An exact retry of an already committed desired value returns that stored item before a stale `ifVersion` becomes a conflict, preserving its version and timestamps; a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize. + +A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection. + +`maxNoteBytes` is a required deployment choice and bounds the UTF-8 byte length of an optional note; the Web Host bundle sets it explicitly to `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` contract directly through `GatewayService` and `@Remote`. Client Remote aggregate mounting and UI remain separately owned and deferred; their later adapter stays a thin consumer of this Host contract. + +The service performs no fake deletion cascade. `session/disposed` and `host/session-removed` describe detach from live ownership, not durable Session deletion, and Session persistence currently has no delete surface. Sidecar rows can therefore remain after out-of-band log removal; a different `{createdAt, cwd}` prevents such an orphan from becoming feedback for a later Session that reuses the id. + +## Alternatives considered + +**Append edits to the Session log and derive a projection.** Rejected because editable UI metadata would become canonical conversation-adjacent history, forks would replay and inherit it, deletion would require tombstones, and reusing `feedback/record` would silently couple a message rating to telemetry consent. + +**Key feedback globally by `MessageId`, copy it on fork, or use one Session revision.** Rejected because message ids are meaningful only within a Session lifecycle, forked conversations need independent human judgments, and unrelated message mutations must not create false conflicts. + +**Extend `KvTable` with cross-process compare-and-swap in this change.** Rejected because the shipped storage-domain backends expose no common conditional-write primitive. A process-local queue matches the supported one-Host topology; a real multi-process guarantee requires a backend-level atomic contract and is separate work. + +**Delete feedback on Session disposal.** Rejected because disposal includes ordinary detach and rollback paths. Treating it as durable deletion would lose feedback while the Session log still exists; cleanup waits for a real Session deletion authority. + +## Consequences + +Message feedback is locally durable and independently editable without changing model-visible history or telemetry behavior. Concurrent callers in one Host receive per-message conflict detection and retry-safe outcomes, while deployments with multiple writers to the same storage root remain unsupported. A differing header identity treats a stale row as absent but does not reclaim it; a cloned log that retains the same `{createdAt, cwd}` is indistinguishable by this contract. The Host Remote contract is available now; client assembly and UI can remain thin consumers rather than taking ownership of persistence or concurrency semantics. diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md new file mode 100644 index 0000000000..ee67d0d694 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 绑定生命周期的消息反馈伴随记录 + +Status: implemented + +[English](2026-08-10-message-feedback-sidecar.md) | 中文 + +## 问题 + +现有 `/feedback` 命令记录不可变的 Session 级 `feedback/record` 事件。在 `FEEDBACK_ONLY` 下,该事件可以释放待处理的遥测前缀,因此它不适合作为挂在单条 assistant 消息上的可编辑好评/差评与可选备注的权威来源。消息反馈需要独立的更新与删除语义,且不得进入权威 Session 日志、改变投影、到达模型接口,或隐式表示遥测同意。 + +只按 `SessionId` 建索引的伴随记录可能在该 id 以不同 header 身份重建后,继续存活于其所描述的日志生命周期之外。Session 级 revision 还会让无关消息的编辑彼此冲突,而普通 storage-domain 读/写不提供跨进程 compare-and-swap。Session disposal 只是从 live store 脱离,并非持久删除;当前 Session 持久化 seam 也没有可拥有真实级联的删除操作。 + +## 决策 + +`@deepseek-ai/dsh-message-feedback` 拥有 `ctx.messageFeedback` 服务,并把消息反馈存为每个 Session 一条 storage-domain 伴随记录(sidecar)。该伴随记录既不是 Session 日志内容,也不是 Session 投影。它不发出 `feedback/record` 事件,也不执行遥测交接;command-feedback 与 message-feedback 契约保持独立。 + +每条可用记录都绑定到经检查的 Session header 身份 `{createdAt, cwd}`,而不只是其 `SessionId`。生命周期不匹配按不存在处理:`list` 返回空条目,`put` 可以用绑定当前身份的新记录替换陈旧行。因此,以不同 header 身份复用的 id 不会继承陈旧反馈。fork 拥有自己的 Session 身份,且不复制伴随记录:即使 fork 种子包含相同的 assistant 消息,反馈仍只属于人类记录它的那个 Session。 + +`put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。 + +`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint;已进入目录的 cold Session 则通过 `SessionPersistence.readFrom` 从序列零做物理复读。随后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或 cold 物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。 + +每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。对已提交目标值的完全相同重试,会在陈旧 `ifVersion` 形成冲突前返回已存条目,并保留其 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。 + +按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。 + +`maxNoteBytes` 是必填的部署选择,用于限制可选备注的 UTF-8 字节长度;Web Host bundle 将其显式设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 直接发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 契约。客户端 Remote 聚合挂载与 UI 由各自边界负责并保持延后;后续适配层只是该 Host 契约的薄消费者。 + +服务不伪造删除级联。`session/disposed` 与 `host/session-removed` 表示脱离 live ownership,而非持久删除,Session persistence 当前也没有删除接口。因此在带外移除日志后,伴随记录可能继续存在;不同的 `{createdAt, cwd}` 可阻止此类孤儿记录变成后来复用该 id 的 Session 反馈。 + +## 考虑过的替代方案 + +**把编辑追加到 Session 日志并派生投影。** 不予采纳,因为可编辑 UI 元数据会变成权威且邻近对话的历史,fork 会回放并继承它,删除需要 tombstone,而复用 `feedback/record` 会把消息评分与遥测同意静默耦合。 + +**按全局 `MessageId` 建索引、在 fork 时复制,或使用一个 Session revision。** 不予采纳,因为消息 id 仅在某个 Session 生命周期内有意义,fork 后的对话需要独立的人类判断,而且无关消息的变更不应制造虚假冲突。 + +**在本次变更中为 `KvTable` 扩展跨进程 compare-and-swap。** 不予采纳,因为出厂 storage-domain 后端没有共同的条件写原语。进程内队列符合受支持的单 Host 拓扑;真实的多进程保证需要后端级原子契约,属于独立工作。 + +**在 Session disposal 时删除反馈。** 不予采纳,因为 disposal 包含普通 detach 与 rollback 路径。把它当成持久删除会在 Session 日志仍存在时丢失反馈;清理必须等待真正的 Session 删除权威。 + +## 后果 + +消息反馈在本地持久化并可独立编辑,且不改变模型可见历史或遥测行为。同一 Host 中的并发调用方获得逐消息冲突检测与可安全重试的结果;多个写入者共享同一存储根目录的部署仍不受支持。不同的 header 身份会让陈旧记录被视为不存在,但不会将其回收;本契约无法区分保留相同 `{createdAt, cwd}` 的克隆日志。Host Remote 契约现在可用;客户端组装与 UI 可以保持为薄消费者,而不接管持久化或并发语义。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 344bac8145..ea8b934c79 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 8d1c5a1be391e2455aefc69db89d96027aaf3efa -architecture.zh.md: 53bf9f54a5503ae40aa62a348822f9d92162dda2 +architecture.md: aeda7f9674e75a1e97549f25c13d571b3b37ee8c +architecture.zh.md: a25f20ba9babefeaab4636027e97d6f2d4ae8caf diff --git a/docs/architecture.md b/docs/architecture.md index 8d1c5a1be3..aeda7f9674 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | +| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | lifecycle-bound editable feedback for individual assistant messages and its Host Remote contract | | `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | durable session-log storage | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools | | `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | log-backed fallbacks, one optional asynchronous provider | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 53bf9f54a5..a25f20ba9b 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -42,6 +42,7 @@ | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | +| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | 绑定生命周期的单条 assistant 消息可编辑反馈及其 Host Remote 契约 | | `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | 会话日志的持久化存储 | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 | | `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | 基于日志的回退标题和单个可选异步提供方 | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index d6c4832447..c0091e400e 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: c102167aa76b9ba613b1b434cb0aa58765d26106 -capability-seams.zh.md: 7c4eab8d5a2d890bdf4513bb9acdc81f55414642 +capability-seams.md: 64d20b1bfb609aec3acb3673e4589516bb315bfa +capability-seams.zh.md: 10b5116d12991319df55c551d51840bb566ac898 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index c102167aa7..64d20b1bfb 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -30,6 +30,7 @@ flowchart LR pkg_session_query_sqlite["session-query-sqlite"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] + pkg_message_feedback["message-feedback"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -60,6 +61,7 @@ flowchart LR pkg_storage_domain["storage-domain"] svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] + svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] svc_workspace["ctx.workspace
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -218,6 +220,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_message_feedback --> svc_messageFeedback pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode @@ -322,6 +325,7 @@ flowchart LR svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude svc_sessionPersistence --> pkg_hooks_codex + svc_sessionPersistence --> pkg_message_feedback svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash @@ -334,6 +338,7 @@ flowchart LR svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants + svc_sessions --> pkg_message_feedback svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite @@ -344,6 +349,7 @@ flowchart LR svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain + svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent @@ -392,16 +398,17 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | -| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | +| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | +| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 7c4eab8d5a..10b5116d12 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -32,6 +32,7 @@ flowchart LR pkg_session_query_sqlite["session-query-sqlite"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] + pkg_message_feedback["message-feedback"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -62,6 +63,7 @@ flowchart LR pkg_storage_domain["storage-domain"] svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] + svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] svc_workspace["ctx.workspace
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -220,6 +222,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_message_feedback --> svc_messageFeedback pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode @@ -324,6 +327,7 @@ flowchart LR svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude svc_sessionPersistence --> pkg_hooks_codex + svc_sessionPersistence --> pkg_message_feedback svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash @@ -336,6 +340,7 @@ flowchart LR svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants + svc_sessions --> pkg_message_feedback svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite @@ -346,6 +351,7 @@ flowchart LR svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain + svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent @@ -394,16 +400,17 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、[`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop)、[`compact-basic`](../packages/compact/compact-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants)、[`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session)、[`agent`](../packages/core/agent)、[`scope`](../packages/core/scope)、[`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader)、[`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json)、[`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 | -| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | +| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace)、[`message-feedback`](../packages/feedback/message-feedback) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | +| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference)、[`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a33d28a415..3693fdf977 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: 4110b89d871605e4286828f229fc45b62df156d8 -config-catalog.zh.md: 3cf2035865f36f18b2395bf8f161157bc5ee9f32 +config-catalog.md: 6e7d6d6214af81cc89c7578454b0004ca0c52e1f +config-catalog.zh.md: e3425e0ab74d2ba8a879a1cfc1acf0d79518743b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4110b89d87..6e7d6d6214 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1129,6 +1129,20 @@ export interface ReconnectConfig { Source: [`packages/mcp/mcp-client/src/index.ts:98`](../packages/mcp/mcp-client/src/index.ts) +## `@deepseek-ai/dsh-message-feedback` + +Requires: `storageDomain` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) + ## `@deepseek-ai/dsh-permission` Requires: `bash` · `approval` · `sessions` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3cf2035865..e3425e0ab7 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1131,6 +1131,20 @@ export interface ReconnectConfig { 来源:[`packages/mcp/mcp-client/src/index.ts:94`](../packages/mcp/mcp-client/src/index.ts) +## `@deepseek-ai/dsh-message-feedback` + +需要:`storageDomain` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} +``` + +来源:[`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) + ## `@deepseek-ai/dsh-permission` 需要:`bash` · `approval` · `sessions` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index b7483ea865..d54af9af70 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: 57577f59f3c773c51680b1e1c6bf53b0bbba4dfa -module-graph.zh.md: 3f97c65e091f3dc0f1ccba23b6984610e445fe73 +module-graph.md: 7eac17bea2849bcc3730b4165eb55de4457ec66d +module-graph.zh.md: e6a8758f50481e849ccf4c33794b5468d81601ee diff --git a/docs/module-graph.md b/docs/module-graph.md index 57577f59f3..7eac17bea2 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -200,6 +200,7 @@ flowchart TD end subgraph group_feedback["packages/feedback"] pkg_command_feedback["command-feedback"] + pkg_message_feedback["message-feedback"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -570,6 +571,13 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_invariants + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_storage_domain + pkg_message_feedback --> pkg_type_meta pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse @@ -1342,6 +1350,7 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 3f97c65e09..e6a8758f50 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -202,6 +202,7 @@ flowchart TD end subgraph group_feedback["packages/feedback"] pkg_command_feedback["command-feedback"] + pkg_message_feedback["message-feedback"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -572,6 +573,13 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_invariants + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_storage_domain + pkg_message_feedback --> pkg_type_meta pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse @@ -1344,6 +1352,7 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 9e578f7886..ebdccb923a 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/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/subsystems/README.md -README.md: fddbf460c8e9e7c6f9ed1d3375bdabe65947661f -README.zh.md: febc5a97426fef5ec4b2b80d9677957369994a3b +README.md: 560851eeda607b762456fe20c874b4497704709f +README.zh.md: 4acba4372e995b54a3bb326bec0cf9606f997773 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index fddbf460c8..560851eeda 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -18,6 +18,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | | [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | +| [feedback.md](feedback.md) | lifecycle-bound per-message feedback records, optimistic versions, sidecar persistence, and the Host Remote contract | | [session-title.md](session-title.md) | durable title snapshots, cited source-message seqs, and the asynchronous provider contract | | [session-reference.md](session-reference.md) | structured cross-session references: `SessionReferenceInput`/`Candidate`, prepared message contexts, the stable error taxonomy | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index febc5a9742..4acba4372e 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -18,6 +18,7 @@ | [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | | [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | +| [feedback.md](feedback.md) | 绑定生命周期的逐消息反馈记录、乐观版本、伴随记录持久化与 Host Remote 契约 | | [session-title.md](session-title.md) | 持久标题快照、被引用的来源消息 seq 与异步提供方约定 | | [session-reference.md](session-reference.md) | 结构化跨会话引用:`SessionReferenceInput`/`Candidate`、prepared 消息上下文、稳定错误分类 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml new file mode 100644 index 0000000000..2dd07e8f8c --- /dev/null +++ b/docs/subsystems/feedback.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 docs/subsystems/feedback.md +feedback.md: 4de02753ef179b13cae90b51ba965c42e2f519ee +feedback.zh.md: 88e51cdb5c07e8dd7499f0ac2449b6b6ecc3388d diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md new file mode 100644 index 0000000000..4de02753ef --- /dev/null +++ b/docs/subsystems/feedback.md @@ -0,0 +1,76 @@ +# Message Feedback + +English | [中文](feedback.zh.md) + +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. It is deliberately separate from the immutable Session-level `feedback/record` event: message feedback is a local storage-domain sidecar, not Session-log content or a projection, and it performs no telemetry handoff. + +Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) + +## Data and concurrency + +One Session sidecar row contains its header identity `{createdAt, cwd}` and feedback items keyed by `MessageId`. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them. + +`put` is optimistic and retry-safe. An exact retry of the already stored desired value returns that item before a stale `ifVersion` is treated as a conflict. Deleting an already absent item also succeeds. A per-Session queue encloses inspection, read, conflict evaluation, and whole-row write, so these guarantees cover concurrent calls in one Host process. + +## Target and lifecycle authority + +`SessionPersistence.inspect()` supplies the target Session observation without publishing or resuming an Agent and without committing cold repair. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session propagates as infrastructure failure. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin, usage-only empty, and non-assistant records are not feedback targets. + +The stored `{createdAt, cwd}` identity must match the inspected header. A mismatch is treated as absence: `list` returns no items, while `put` may replace the stale row with one bound to the current header identity. Forks use a new Session identity and receive no sidecar copy even when their seed contains the same messages. + +## Persistence and Remote contract + +The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold target is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority. + +## Boundaries and limitations + +- The client Remote aggregate mount and UI consumer are separately owned and deferred. +- The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee. +- Session persistence has no durable deletion surface. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal. +- A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. +- Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract. +- The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary. + + + + + +## Cordis surface + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.messageFeedback` — `MessageFeedbackService` + +Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session. + +```ts cordis-catalog +/** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ +@Remote('list') async list(request: MessageFeedbackListRequest): Promise + +/** + * Create or replace feedback for one derived append-origin assistant + * message. An exact desired-value retry returns the stored item before its + * stale or `null` version is considered a conflict. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ +@Remote('put') put(request: MessageFeedbackPutRequest): Promise + +/** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ +@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts) + diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md new file mode 100644 index 0000000000..88e51cdb5c --- /dev/null +++ b/docs/subsystems/feedback.zh.md @@ -0,0 +1,76 @@ +# 消息反馈 + +[English](feedback.md) | 中文 + +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。它刻意与不可变的 Session 级 `feedback/record` 事件分离:message feedback 是本地 storage-domain 伴随记录(sidecar),不是 Session 日志内容或投影,也不执行遥测交接。 + +来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) + +## 数据与并发 + +每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。 + +`put` 采用乐观并发并可安全重试。若目标值已经存储,完全相同的重试会先返回该条目,再考虑陈旧 `ifVersion` 是否构成冲突。删除已经不存在的条目也同样成功。按 Session 划分的队列覆盖检查、读取、冲突判断与整行写入,因此这些保证适用于单个 Host 进程中的并发调用。 + +## 目标与生命周期权威 + +`SessionPersistence.inspect()` 提供目标 Session 的观测,且不会发布或恢复 Agent,也不会提交 cold repair。cold 路径先由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,会按基础设施故障原样传播。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin、仅承载 usage 的空记录和非 assistant 记录都不是反馈目标。 + +存储的 `{createdAt, cwd}` 身份必须与检查所得 header 匹配。不匹配按不存在处理:`list` 返回空条目,`put` 则可用绑定当前 header 身份的新记录替换陈旧行。fork 使用新的 Session 身份,即使种子包含相同消息,也不获得伴随记录副本。 + +## 持久化与 Remote 契约 + +服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;已进入目录的 cold 目标则通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。 + +## 边界与限制 + +- 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。 +- 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。 +- Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。 +- 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 +- 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。 +- Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。 + + + + + +## Cordis surface + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.messageFeedback` — `MessageFeedbackService` + +Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session. + +```ts cordis-catalog +/** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ +@Remote('list') async list(request: MessageFeedbackListRequest): Promise + +/** + * Create or replace feedback for one derived append-origin assistant + * message. An exact desired-value retry returns the stored item before its + * stale or `null` version is considered a conflict. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ +@Remote('put') put(request: MessageFeedbackPutRequest): Promise + +/** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ +@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts) + diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index fb41923d71..1b26158b47 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -60,6 +60,11 @@ config: backend: json + - id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 + - id: workspace name: '@deepseek-ai/dsh-workspace' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 181bb269c4..f5e5ffe4e5 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -82,6 +82,7 @@ "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml index fce2946cff..bab1cf0db2 100644 --- a/packages/feedback/README.i18n.yaml +++ b/packages/feedback/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/feedback/README.md -README.md: af8e9d5c4903594299284d09f880aa8929f5e051 -README.zh.md: 9156ff1c8da8ed1128488fcacaf454425468163a +README.md: 152db65bd7ac179bb8d475d446535734b9f8238e +README.zh.md: 64202a3c4a0258f9b41a6cd96e7bbbf011d0338d diff --git a/packages/feedback/README.md b/packages/feedback/README.md index af8e9d5c49..152db65bd7 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -2,10 +2,13 @@ English | [中文](README.zh.md) -The feedback family lets a human record a remark about the session without acting on it. Feedback is durable session-log content, separate from the model conversation and from any policy that might later read it. +The feedback family exposes two deliberately separate contracts: an immutable remark in the canonical Session log, and editable feedback attached to one assistant message in a local sidecar. Neither form enters the model conversation. | Package | Role | ctx key | |---|---|---| | `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — | +| `message-feedback/` | Lifecycle-bound per-message rating/note sidecar plus Host `messageFeedback.list/put/delete` Remote contract | `messageFeedback` | -A recorded remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy. +A command feedback remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy. + +Message feedback is not a Session event or projection. It remains in the storage-domain sidecar and causes no telemetry handoff. The Host Remote contract ships with the service; the client Remote aggregate mount and UI consumer are separately owned and deferred. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md index 9156ff1c8d..64202a3c4a 100644 --- a/packages/feedback/README.zh.md +++ b/packages/feedback/README.zh.md @@ -2,10 +2,13 @@ [English](README.md) | 中文 -反馈家族让人类记录对会话的评价,但不据此采取任何动作。反馈属于持久的会话日志内容,与模型对话以及后续可能读取它的任何策略相互独立。 +反馈家族公开两份刻意分离的契约:写入权威 Session 日志的不可变评价,以及挂在单条 assistant 消息上的可编辑本地伴随记录(sidecar)反馈。两者都不会进入模型对话。 | 包 | 职责 | ctx 键 | |---|---|---| | `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 | +| `message-feedback/` | 绑定生命周期的逐消息评分/备注伴随记录,以及 Host `messageFeedback.list/put/delete` Remote 契约 | `messageFeedback` | -被记录的评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。 +command feedback 评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。 + +message feedback 不是 Session 事件或投影。它只保留在 storage-domain 伴随记录中,不触发任何遥测交接。服务随附 Host Remote 契约;客户端 Remote 聚合挂载与 UI 消费方由各自边界负责,并保持延后。 diff --git a/packages/feedback/message-feedback/README.i18n.yaml b/packages/feedback/message-feedback/README.i18n.yaml new file mode 100644 index 0000000000..2ff326dc3a --- /dev/null +++ b/packages/feedback/message-feedback/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/feedback/message-feedback/README.md +README.md: 0fda4fb535e5186c252377383520671cd574b364 +README.zh.md: 151db5c10048e6ff32d3f7a4ddbcba986d87597a diff --git a/packages/feedback/message-feedback/README.md b/packages/feedback/message-feedback/README.md new file mode 100644 index 0000000000..0fda4fb535 --- /dev/null +++ b/packages/feedback/message-feedback/README.md @@ -0,0 +1,81 @@ +# @deepseek-ai/dsh-message-feedback + +English | [中文](README.zh.md) + +Host-owned editable feedback for one finalized assistant message. The package registers `ctx.messageFeedback`, persists one lifecycle-bound sidecar row per Session in storage-domain, and publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract. It is separate from the immutable Session-level `feedback/record` event and performs no telemetry handoff. The [message-feedback sidecar Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md) owns the design boundary. + +Public request, value, version, and failure types are exported from the package root and `@deepseek-ai/dsh-message-feedback/types`; [`src/types.ts`](src/types.ts) is their source. + +## Configuration + +| key | meaning | +|---|---| +| `maxNoteBytes` | Required positive safe integer: maximum UTF-8 byte length of one optional note. | + +Notes must contain at least one non-whitespace character, but accepted text is stored verbatim rather than trimmed. Omitting `note` means the desired value has no note, so an authorized material `put` clears an existing note. + +```yaml +- id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 +``` + +The service injects `storageDomain`, `sessionPersistence`, and `sessions`. Its durable domain is `message_feedback`, with one `sessions` table row per `SessionId`. + +## Data, lifecycle, and durability + +`MessageFeedbackItem` contains `messageId`, `rating: 'positive' | 'negative'`, optional `note`, an opaque equality-only `version`, and Host-assigned `createdAt`/`updatedAt` Unix-millisecond timestamps. A material update preserves `createdAt`, replaces `version`, and keeps `updatedAt` from moving backward. `list` returns fresh immutable snapshots in first-creation order; updating an item retains its place, while deleting and later recreating it appends a new item. + +Each stored row carries the inspected Session header identity `{createdAt, cwd}`. A mismatch is treated as absence: `list` returns an empty `items` array, `delete` returns the absent postcondition, and `put` may replace the stale row with one bound to the current identity. This fences a reused `SessionId` when its header identity differs. Forks use a distinct Session identity and receive no feedback-row copy. + +`SessionPersistence.inspect()` supplies a cold-safe observation without publishing or resuming an Agent and without committing cold repair. For a Session without a live owner, `listSnapshots()` first decides definite absence; an `inspect()` failure for a catalogued Session remains an infrastructure failure rather than being guessed into `session-not-found`. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin messages, empty usage-only assistant records, and non-assistant records return `target-not-found`. + +After initial validation, `put` establishes a durability barrier before writing the sidecar. A matching live Session commits through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold Session is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are validated again. A missing flush participant, changed identity, vanished target, or cold physical-read failure prevents the sidecar commit, so durable feedback never precedes the durable target message. + +Message feedback is not Session-log content or a Session projection. It emits no `feedback/record` event, does not enter model history, and does not trigger `FEEDBACK_ONLY` telemetry release. + +## Service and Host Remote contract + +The same three `MessageFeedbackService` methods are published by `GatewayService` and `@Remote`; the Host endpoint names are `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete`. Every method returns a discriminated business union: `{ ok: true, value }` or `{ ok: false, error }`. Operational storage, corruption, or missing-durability-listener failures reject instead of being mislabeled as business errors. + +| Method | Request | Success `value` | Rejected `error.code` | +|---|---|---|---| +| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` | +| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | committed `MessageFeedbackItem` | `session-not-found`, `target-not-found`, `version-conflict`, `note-blank`, `note-too-large` | +| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`, `version-conflict` | + +`MessageFeedbackVersionConflict` returns the caller's `expected` token and the current `actual` token, each nullable where absence is meaningful. `MessageFeedbackNoteTooLarge` returns both `maxBytes` and `actualBytes`. The Client Remote aggregate does not mount the generated client contribution yet; Host callers can use the service/Remote contract without that client assembly. + +## Compare-and-set and idempotency + +`ifVersion: null` requests creation only; a material update requires the exact current item version. The check is per message rather than per Session, so changing one item does not conflict with another. Every material create or update assigns a fresh opaque UUID token. + +An exact desired-value retry is recognized before `ifVersion` comparison. It returns the already stored item with unchanged version and timestamps, so a caller may safely retry after losing a success response even with the now-stale token or original `null`. `delete` ignores `ifVersion` when the item is already absent and always returns the stable `{ absent: true }` postcondition after success. + +A per-Session promise queue encloses inspection, durability validation, sidecar read, comparison, and whole-row write. These semantics serialize concurrent mutations through one service instance; storage-domain itself has no cross-process conditional write. + +## Model Experience + +### Local message-feedback state + +#### What the model sees + +Nothing. `ctx.messageFeedback` registers no tool, prompt section, model-facing context, or Session event; feedback stays in a Host-owned sidecar unless a separately documented Consumer explicitly exposes it. + +#### Token effect + +Zero. No request, result, rating, note, timestamp, or failure from this package enters a model request. + +#### KV Cache effect + +Independent. Listing or mutating message feedback does not touch a model request prefix and cannot invalidate an otherwise reusable provider cache entry. + +## Known Limitations and Deferred Work + +- **Client aggregate and UI are absent** — the Host Remote contract ships, but the Client Remote aggregate contribution and any UI consumer are separately owned and deferred. +- **Compare-and-set is single-process** — the per-Session queue serializes one service instance only; multiple Host processes writing one storage root can still lose updates because storage-domain exposes no cross-process conditional write. +- **No durable Session deletion cascade** — Session persistence has no deletion surface, and `session/disposed`/`host/session-removed` mean detach rather than durable deletion. The service therefore retains empty rows and may leave orphan rows after out-of-band log removal instead of deleting valid feedback on detach. +- **Detach/catalog retirement window** — a request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. +- **Header identity is not a content fingerprint** — `{createdAt, cwd}` detects reuse only when those fields differ; a cloned log retaining the same header identity is indistinguishable. +- **Trusted caller boundary** — `list`/`put`/`delete` carry no authenticated actor or audit identity. A deployment must expose the Host gateway only through its trusted or separately authenticated boundary until authorization and attribution are added. diff --git a/packages/feedback/message-feedback/README.zh.md b/packages/feedback/message-feedback/README.zh.md new file mode 100644 index 0000000000..151db5c100 --- /dev/null +++ b/packages/feedback/message-feedback/README.zh.md @@ -0,0 +1,81 @@ +# @deepseek-ai/dsh-message-feedback + +[English](README.md) | 中文 + +本包提供由 Host 拥有、针对单条已完成 assistant 消息的可编辑反馈。它注册 `ctx.messageFeedback`,在 storage-domain 中为每个 Session 持久化一条绑定生命周期的伴随记录(sidecar),并发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 一元 Remote 契约。它与不可变的 Session 级 `feedback/record` 事件相互独立,不执行遥测交接。[消息反馈伴随记录 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md)拥有其设计边界。 + +公开的请求、值、版本与失败类型从包根入口及 `@deepseek-ai/dsh-message-feedback/types` 导出;其源码为 [`src/types.ts`](src/types.ts)。 + +## 配置 + +| 键 | 含义 | +|---|---| +| `maxNoteBytes` | 必填正 safe integer:一条可选备注的最大 UTF-8 字节长度。 | + +备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此通过授权的实质 `put` 会清除已有备注。 + +```yaml +- id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 +``` + +服务注入 `storageDomain`、`sessionPersistence` 与 `sessions`。其持久存储域为 `message_feedback`,其中 `sessions` 表按 `SessionId` 每个一行。 + +## 数据、生命周期与持久性 + +`MessageFeedbackItem` 包含 `messageId`、`rating: 'positive' | 'negative'`、可选 `note`、只能做相等比较的 opaque `version`,以及由 Host 分配、以 Unix 毫秒表示的 `createdAt`/`updatedAt` 时间戳。实质更新保留 `createdAt`、替换 `version`,并保证 `updatedAt` 不倒退。`list` 按首次创建顺序返回新的不可变快照;更新条目时保留其位置,删除后再创建则追加为新条目。 + +每条存储行都携带检查所得 Session header 身份 `{createdAt, cwd}`。不匹配按不存在处理:`list` 返回空 `items` 数组,`delete` 返回已不存在的后置条件,`put` 可以用绑定当前身份的新行替换陈旧行。这会在复用的 `SessionId` 具有不同 header 身份时形成隔离。fork 使用独立的 Session 身份,不复制反馈伴随记录。 + +`SessionPersistence.inspect()` 提供 cold-safe 观测,不发布或恢复 Agent,也不提交 cold repair。对于没有 live owner 的 Session,系统先用 `listSnapshots()` 判定明确不存在;已进入目录的 Session 若 `inspect()` 失败,仍属于基础设施故障,不会被猜测成 `session-not-found`。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin 消息、仅承载 usage 的空 assistant 记录与非 assistant 记录都返回 `target-not-found`。 + +初步校验后,`put` 在写入伴随记录前建立 durability barrier。身份匹配的 live Session 通过权威 `ctx.sessions.flush` checkpoint 提交;已进入目录的 cold Session 则通过 `SessionPersistence.readFrom` 从序列零做物理复读。随后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或 cold 物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。 + +message feedback 不是 Session 日志内容或 Session 投影。它不发出 `feedback/record` 事件,不进入模型历史,也不触发 `FEEDBACK_ONLY` 遥测释放。 + +## 服务与 Host Remote 契约 + +`GatewayService` 与 `@Remote` 将 `MessageFeedbackService` 的同三个方法发布出去;Host endpoint 名称为 `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete`。每个方法都返回判别式业务 union:`{ ok: true, value }` 或 `{ ok: false, error }`。存储、损坏或缺少 durability listener 等操作故障会产生 reject,不会被误标为业务错误。 + +| 方法 | 请求 | 成功 `value` | 拒绝的 `error.code` | +|---|---|---|---| +| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` | +| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | 已提交的 `MessageFeedbackItem` | `session-not-found`、`target-not-found`、`version-conflict`、`note-blank`、`note-too-large` | +| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`、`version-conflict` | + +`MessageFeedbackVersionConflict` 返回调用方的 `expected` token 与当前 `actual` token;在表示不存在时,两者可以为 null。`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes` 与 `actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。 + +## Compare-and-set 与幂等性 + +`ifVersion: null` 表示仅当条目不存在时才创建;实质更新要求与当前条目 version 完全一致。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token。 + +系统会在比较 `ifVersion` 之前识别与目标值完全相同的重试。它返回已存条目,version 与时间戳均不变,因此调用方在成功响应丢失后,即使用当前已陈旧的 token 或原始 `null` 也可安全重试。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。 + +按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。 + +## 模型体验 + +### 本地消息反馈状态 + +#### 模型看到的内容 + +无。`ctx.messageFeedback` 不注册工具、提示词段落、模型可见上下文或 Session 事件;除非另一个具有独立文档的 Consumer 显式公开反馈,否则它只留在 Host 拥有的伴随记录中。 + +#### Token 影响 + +为零。本包的请求、结果、评分、备注、时间戳或失败都不会进入模型请求。 + +#### KV Cache 影响 + +相互独立。读取或变更消息反馈不会触碰模型请求前缀,也不会使本可复用的提供方缓存条目失效。 + +## 已知局限与延后工作 + +- **缺少客户端聚合与 UI**——Host Remote 契约已经发布,但客户端 Remote 聚合 contribution 与任何 UI 消费方由各自边界负责并保持延后。 +- **Compare-and-set 仅限单进程**——按 Session 划分的队列只串行化一个服务实例;storage-domain 不提供跨进程条件写,因此多个 Host 进程写入同一存储根目录时仍可能丢失更新。 +- **没有持久 Session 删除级联**——Session persistence 没有删除接口,且 `session/disposed`/`host/session-removed` 表示 detach 而非持久删除。因此服务会保留空行,并可能在带外移除日志后留下孤儿行,而不会在 detach 时删除仍有效的反馈。 +- **Detach/catalog retirement 窗口**——请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 +- **Header 身份不是内容指纹**——只有 `{createdAt, cwd}` 不同时才能识别复用;本契约无法区分保留相同 header 身份的克隆日志。 +- **调用方边界受信任**——`list`/`put`/`delete` 不携带已认证的 actor 或审计身份。在加入授权与归属信息前,部署方必须只通过受信任或另行认证的边界暴露 Host gateway。 diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json new file mode 100644 index 0000000000..5a07238ec9 --- /dev/null +++ b/packages/feedback/message-feedback/package.json @@ -0,0 +1,82 @@ +{ + "name": "@deepseek-ai/dsh-message-feedback", + "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", + "version": "0.0.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/feedback/message-feedback" + }, + "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" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts new file mode 100644 index 0000000000..ce2e9d06f3 --- /dev/null +++ b/packages/feedback/message-feedback/src/index.ts @@ -0,0 +1,380 @@ +/** + * Durable, lifecycle-bound feedback for finalized assistant messages. + * @module @deepseek-ai/dsh-message-feedback + */ + +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { Context, Service } from '@deepseek-ai/cordis' +import s from '@deepseek-ai/schemastery' +import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import type { KvTable } from '@deepseek-ai/dsh-storage-domain' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' +import { messageFeedbackDomainSpec } from './spec.ts' +import type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts' +import type { + MessageFeedbackDeleteRequest, + MessageFeedbackDeleteResult, + MessageFeedbackDeleteValue, + MessageFeedbackFailure, + MessageFeedbackItem, + MessageFeedbackListRequest, + MessageFeedbackListResult, + MessageFeedbackListValue, + MessageFeedbackNoteBlank, + MessageFeedbackNoteTooLarge, + MessageFeedbackPutRequest, + MessageFeedbackPutResult, + MessageFeedbackRejected, + MessageFeedbackSessionNotFound, + MessageFeedbackSuccess, + MessageFeedbackVersion, + MessageFeedbackVersionConflict, +} from './types.ts' + +export type * from './types.ts' +export { + messageFeedbackDomainSpec, + messageFeedbackItemSchema, + messageFeedbackRatingSchema, + messageFeedbackRowSchema, + messageFeedbackSessionIdentitySchema, + messageFeedbackVersionSchema, +} from './spec.ts' +export type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts' + +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} + +declare module '@deepseek-ai/cordis' { + interface Context { + messageFeedback: MessageFeedbackService + } +} + +/** Immutable empty list reused only as an input to caller-owned copying. */ +const EMPTY_ITEMS: readonly MessageFeedbackItem[] = Object.freeze([]) + +/** Validate the one deployment-varying limit at the configuration boundary. */ +function resolveMaxNoteBytes(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new TypeError( + `message-feedback: maxNoteBytes must be a positive safe integer, got ${String(value)}`, + ) + } + return value +} + +/** Copy and freeze one item before it crosses the service boundary. */ +function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem { + return Object.freeze({ + messageId: item.messageId, + rating: item.rating, + ...(item.note === undefined ? {} : { note: item.note }), + version: item.version, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }) +} + +/** Copy and freeze a list response. */ +function snapshotList(items: readonly MessageFeedbackItem[]): MessageFeedbackListValue { + return Object.freeze({ items: Object.freeze(items.map(snapshotItem)) }) +} + +/** Build a frozen success branch. */ +function success(value: T): MessageFeedbackSuccess { + return Object.freeze({ ok: true, value }) +} + +/** Build a frozen business-failure branch. */ +function rejected(error: E): MessageFeedbackRejected { + return Object.freeze({ ok: false, error: Object.freeze(error) }) +} + +/** Project the Session fields that distinguish one persisted log lifecycle. */ +function identityOf(header: SessionHeader): MessageFeedbackSessionIdentity { + return Object.freeze({ + createdAt: header.createdAt, + ...(header.cwd === undefined ? {} : { cwd: header.cwd }), + }) +} + +/** Whether a stored row belongs to the inspected Session lifecycle. */ +function sameIdentity(row: MessageFeedbackRow, header: SessionHeader): boolean { + return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd +} + +/** Whether two observations name the same persisted Session lifecycle. */ +function sameHeaderIdentity(left: SessionHeader, right: SessionHeader): boolean { + return left.id === right.id && left.createdAt === right.createdAt && left.cwd === right.cwd +} + +/** Freeze the replacement row so storage-domain never exposes mutable aliases. */ +function rowSnapshot( + session: MessageFeedbackSessionIdentity, + items: readonly MessageFeedbackItem[], +): MessageFeedbackRow { + const copiedItems = items.map(snapshotItem) + Object.freeze(copiedItems) + return Object.freeze({ + session, + items: copiedItems, + }) +} + +/** Generate an opaque equality token for one material mutation. */ +function nextVersion(): MessageFeedbackVersion { + return randomUUID() as MessageFeedbackVersion +} + +/** Session inspection result that keeps absence inside the business union. */ +type KnownSession = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** Validated note or one explicit request failure. */ +type ResolvedNote = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** + * Storage-domain sidecar service. It inspects persisted Session history and + * never creates or resumes an Agent or Session. + */ +export class MessageFeedbackService extends GatewayService { + static inject = ['storageDomain', 'sessionPersistence', 'sessions'] + + /** Loader validation for the required note-size policy. */ + static Config: s = s.object({ + maxNoteBytes: s.number().step(1).min(1).required(), + }) + + private readonly maxNoteBytes: number + private table?: KvTable + private readonly operationTails = new Map>() + + /** + * @param ctx - Host context carrying persistence and the storage-domain form. + * @param config - Required note-size policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'messageFeedback') + this.maxNoteBytes = resolveMaxNoteBytes(config.maxNoteBytes) + } + + /** Open and own the one message-feedback sidecar domain. */ + protected async [Service.init](): Promise { + const domain = await this.ctx.storageDomain.open(messageFeedbackDomainSpec) + this.ctx.effect(() => () => domain.close(), 'message-feedback.domainClose') + this.table = domain.table('sessions') + } + + /** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ + @Remote('list') + async list(request: MessageFeedbackListRequest): Promise { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + const row = this.requireTable().get(request.sessionId) + const items = row !== undefined && sameIdentity(row, known.value.meta) ? row.items : EMPTY_ITEMS + return success(snapshotList(items)) + } + + /** + * Create or replace feedback for one derived append-origin assistant + * message. An exact desired-value retry returns the stored item before its + * stale or `null` version is considered a conflict. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ + @Remote('put') + put(request: MessageFeedbackPutRequest): Promise { + const note = this.resolveNote(request.note) + if (!note.ok) return Promise.resolve(note) + return this.enqueue(request.sessionId, async () => { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + if (!this.hasFeedbackTarget(known.value, request.messageId)) { + return rejected({ + code: 'target-not-found', + sessionId: request.sessionId, + messageId: request.messageId, + }) + } + + const durable = await this.ensureTargetDurable(known.value) + if (!sameHeaderIdentity(durable.meta, known.value.meta) + || !this.hasFeedbackTarget(durable, request.messageId)) { + return rejected({ + code: 'target-not-found', + sessionId: request.sessionId, + messageId: request.messageId, + }) + } + + const table = this.requireTable() + const stored = table.get(request.sessionId) + const current = stored !== undefined && sameIdentity(stored, durable.meta) ? stored : undefined + const items = current?.items ?? EMPTY_ITEMS + const index = items.findIndex(item => item.messageId === request.messageId) + const existing = items[index] + if (existing !== undefined + && existing.rating === request.rating + && existing.note === note.value) { + return success(snapshotItem(existing)) + } + if (request.ifVersion !== (existing?.version ?? null)) { + return rejected(this.versionConflict(request, existing?.version ?? null)) + } + + const now = Date.now() + const item = snapshotItem({ + messageId: request.messageId, + rating: request.rating, + ...(note.value === undefined ? {} : { note: note.value }), + version: nextVersion(), + createdAt: existing?.createdAt ?? now, + updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt), + }) + const nextItems = [...items] + if (index === -1) nextItems.push(item) + else nextItems[index] = item + await table.put( + request.sessionId, + rowSnapshot(identityOf(durable.meta), nextItems), + ) + return success(snapshotItem(item)) + }) + } + + /** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ + @Remote('delete') + delete(request: MessageFeedbackDeleteRequest): Promise { + return this.enqueue(request.sessionId, async () => { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + + const table = this.requireTable() + const stored = table.get(request.sessionId) + const current = stored !== undefined && sameIdentity(stored, known.value.meta) ? stored : undefined + const items = current?.items ?? EMPTY_ITEMS + const existing = items.find(item => item.messageId === request.messageId) + if (existing === undefined) { + return success(Object.freeze({ absent: true })) + } + if (request.ifVersion !== existing.version) { + return rejected(this.versionConflict(request, existing.version)) + } + + await table.put( + request.sessionId, + rowSnapshot(identityOf(known.value.meta), items.filter(item => item !== existing)), + ) + return success(Object.freeze({ absent: true })) + }) + } + + /** + * Resolve a live owner directly; otherwise use the storage catalog as the + * existence authority before inspecting the log. Inspection failures for a + * catalogued Session remain infrastructure failures rather than being + * guessed into the business `session-not-found` branch. + */ + private async inspectSession(sessionId: SessionId): Promise { + if (this.ctx.sessions.get(sessionId) === undefined) { + const snapshots = await this.ctx.sessionPersistence.listSnapshots() + if (!snapshots.some(snapshot => snapshot.header.id === sessionId)) { + return rejected({ code: 'session-not-found', sessionId }) + } + } + return success(await this.ctx.sessionPersistence.inspect(sessionId)) + } + + /** Require the exact finalized append-origin assistant message projection. */ + private hasFeedbackTarget(inspection: SessionInspection, messageId: MessageFeedbackItem['messageId']): boolean { + return inspection.events.some((event) => { + if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) return false + const message = deriveEventMessage(event) + return message?.role === 'assistant' && message.id === messageId + }) + } + + /** + * Put the target log prefix behind a durability barrier before its sidecar. + * A live owner flushes through the SessionStore's canonical checkpoint; a + * cold owner is re-read from the physical durable prefix. + */ + private async ensureTargetDurable(inspection: SessionInspection): Promise { + const live = this.ctx.sessions.get(inspection.meta.id) + if (live !== undefined && sameHeaderIdentity(live.header, inspection.meta)) { + if (!(await this.ctx.sessions.flush(live))) { + throw new Error( + `message-feedback: no durability listener participated for live session '${inspection.meta.id}'`, + ) + } + return inspection + } + return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) + } + + /** Validate optional-note semantics and the configured complete UTF-8 byte bound. */ + private resolveNote(note: string | undefined): ResolvedNote { + if (note === undefined) return success(undefined) + if (note.trim().length === 0) return rejected({ code: 'note-blank' }) + const actualBytes = Buffer.byteLength(note, 'utf8') + if (actualBytes > this.maxNoteBytes) { + return rejected({ code: 'note-too-large', maxBytes: this.maxNoteBytes, actualBytes }) + } + return success(note) + } + + /** Build a conflict branch without exposing an orderable version. */ + private versionConflict( + request: Pick, + actual: MessageFeedbackVersion | null, + ): MessageFeedbackVersionConflict { + return { + code: 'version-conflict', + sessionId: request.sessionId, + messageId: request.messageId, + expected: request.ifVersion, + actual, + } + } + + /** Queue a complete read/compare/write mutation behind this Session's prior mutation. */ + private enqueue(sessionId: SessionId, operation: () => Promise): Promise { + const previous = this.operationTails.get(sessionId) ?? Promise.resolve() + const result = previous.then(operation) + const tail = result.then(() => undefined, () => undefined) + this.operationTails.set(sessionId, tail) + return result.finally(() => { + if (this.operationTails.get(sessionId) === tail) this.operationTails.delete(sessionId) + }) + } + + /** Resolve the initialized durable table or fail a broken service lifecycle. */ + private requireTable(): KvTable { + if (this.table === undefined) { + throw new Error('message-feedback: durable domain is not initialized') + } + return this.table + } +} + +export default MessageFeedbackService diff --git a/packages/feedback/message-feedback/src/invariant.ts b/packages/feedback/message-feedback/src/invariant.ts new file mode 100644 index 0000000000..5433f318f1 --- /dev/null +++ b/packages/feedback/message-feedback/src/invariant.ts @@ -0,0 +1,27 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-message-feedback/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-message-feedback' + +/** Cordis companion plugin name. */ +export const name = 'message-feedback-invariant' +/** Services required before the companion can reserve and check package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the private typed writer owns current row mutations, + * the domain schema validates rows on reopen, and no second authority exists. + */ +const install: InvariantInstaller = Object.assign(() => {}, { inject: ['messageFeedback'] }) + +/** + * 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/feedback/message-feedback/src/spec.ts b/packages/feedback/message-feedback/src/spec.ts new file mode 100644 index 0000000000..5dcefd9cfe --- /dev/null +++ b/packages/feedback/message-feedback/src/spec.ts @@ -0,0 +1,88 @@ +/** + * Durable storage-domain declaration for lifecycle-bound message feedback. + * @module @deepseek-ai/dsh-message-feedback/src/spec + */ + +import { z } from 'zod' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' +import type { MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion } from './types.ts' + +const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + +/** Runtime schema for the closed rating vocabulary. */ +export const messageFeedbackRatingSchema = z.union([ + z.literal('positive'), + z.literal('negative'), +]) satisfies z.ZodType + +/** Runtime schema for one opaque item version stored on disk. */ +export const messageFeedbackVersionSchema = z.uuid() + .transform(value => value as MessageFeedbackVersion) + +/** Runtime schema for one current feedback item. */ +export const messageFeedbackItemSchema = z.object({ + messageId: z.string().min(1).transform(value => value as MessageId), + rating: messageFeedbackRatingSchema, + note: z.string().refine(note => note.trim().length > 0, { + message: 'message feedback note must contain a non-whitespace character', + }).optional(), + version: messageFeedbackVersionSchema, + createdAt: nonNegativeSafeInteger, + updatedAt: nonNegativeSafeInteger, +}).refine(item => item.updatedAt >= item.createdAt, { + path: ['updatedAt'], + message: 'message feedback updatedAt must not precede createdAt', +}) as unknown as z.ZodType + +/** Persisted Session fields that fence a sidecar row to one log lifecycle. */ +export const messageFeedbackSessionIdentitySchema = z.object({ + createdAt: nonNegativeSafeInteger, + cwd: z.string().optional(), +}) + +/** Persisted lifecycle identity inferred from its durable schema. */ +export type MessageFeedbackSessionIdentity = z.infer + +/** + * One whole-Session sidecar. Duplicate message ids would make item lookup + * ambiguous; duplicate versions would break their independent identity. + */ +export const messageFeedbackRowSchema = z.object({ + session: messageFeedbackSessionIdentitySchema, + items: z.array(messageFeedbackItemSchema), +}).superRefine((row, ctx) => { + const messageIds = new Set() + const versions = new Set() + row.items.forEach((item, index) => { + if (messageIds.has(item.messageId)) { + ctx.addIssue({ + code: 'custom', + path: ['items', index, 'messageId'], + message: `duplicate message feedback id '${item.messageId}'`, + }) + } + messageIds.add(item.messageId) + if (versions.has(item.version)) { + ctx.addIssue({ + code: 'custom', + path: ['items', index, 'version'], + message: `duplicate message feedback version '${item.version}'`, + }) + } + versions.add(item.version) + }) +}) + +/** Durable sidecar row inferred from {@link messageFeedbackRowSchema}. */ +export type MessageFeedbackRow = z.infer + +/** One lifecycle-bound sidecar record per Session id. */ +export const messageFeedbackDomainSpec = defineDomain({ + name: 'message_feedback', + version: 0, + tables: { + sessions: domainTable(messageFeedbackRowSchema), + }, +}) diff --git a/packages/feedback/message-feedback/src/types.ts b/packages/feedback/message-feedback/src/types.ts new file mode 100644 index 0000000000..a802bfc05a --- /dev/null +++ b/packages/feedback/message-feedback/src/types.ts @@ -0,0 +1,151 @@ +/** + * Public request, value, and failure vocabulary for per-message feedback. + * This module contains types only so generated Remote clients can consume it + * without importing Host runtime code. + * @module @deepseek-ai/dsh-message-feedback/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Opaque compare-and-set token for one exact feedback item revision. */ +export type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> + +/** The human's overall judgment of one assistant message. */ +export type MessageFeedbackRating = 'positive' | 'negative' + +/** One current feedback value and its opaque mutation token. */ +export interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} + +/** Read all message feedback belonging to one persisted Session lifecycle. */ +export interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} + +/** Current feedback values for one Session, in first-creation order. */ +export interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} + +/** Create or replace feedback for one assistant message. */ +export interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} + +/** Delete feedback for one message after observing its current version. */ +export interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} + +/** Idempotent deletion acknowledgement. */ +export interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} + +/** No persisted Session header exists for the requested id. */ +export interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} + +/** The id does not name a derived, append-origin assistant message. */ +export interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} + +/** A material mutation did not match the addressed item's current version. */ +export interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + readonly sessionId: SessionId + readonly messageId: MessageId + /** Version supplied by the caller (`null` means create-only). */ + readonly expected: MessageFeedbackVersion | null + /** Current version, or `null` when the item does not exist. */ + readonly actual: MessageFeedbackVersion | null +} + +/** A supplied note contains no non-whitespace character. */ +export interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} + +/** A supplied note exceeds the configured UTF-8 byte limit. */ +export interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} + +/** Failures shared by the public message-feedback operations. */ +export type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + +/** Successful public operation result. */ +export interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} + +/** Rejected public operation result with a stable business failure. */ +export interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} + +/** Result returned by the message-feedback `list` operation. */ +export type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** Result returned by the message-feedback `put` operation. */ +export type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > + +/** Result returned by the message-feedback `delete` operation. */ +export type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts new file mode 100644 index 0000000000..fdb7ebe3b2 --- /dev/null +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -0,0 +1,206 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import SessionStore, { + SESSION_FORMAT_VERSION, + Session, + SessionId, + type SessionEvent, + type SessionHeader, +} from '@deepseek-ai/dsh-session' +import SessionPersistence, { + SessionPersistenceRevision, + type SessionInspection, + type SessionLocation, + type SessionPersistenceSnapshot, +} from '@deepseek-ai/dsh-session-persistence' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import MessageFeedbackService from '../src/index.ts' + +export interface MessageFixture { + readonly session: Session + readonly userMessageId: MessageId + readonly assistantMessageIds: readonly [MessageId, MessageId] + readonly emptyAssistantMessageId: MessageId + readonly replacementAssistantMessageId: MessageId +} + +/** Append one deterministic transcript surface used by target-validation tests. */ +export function appendMessageFixture(session: Session): Omit { + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + const user = createUserMessage({ + content: [{ type: 'text', text: 'Question' }], + source: { kind: 'user' }, + }) + session.append('user/message', user, { surfaceOp: 'append' }) + + const first = createAssistantMessage({ + content: [{ type: 'text', text: 'First answer' }], + source: { provider: 'test', model: 'test' }, + }) + const firstEvent = session.append('assistant/message', { + turn: 1, + step: 1, + message: first, + }, { surfaceOp: 'append' }) + const second = createAssistantMessage({ + content: [{ type: 'text', text: 'Second answer' }], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: second, + }, { surfaceOp: 'append' }) + const empty = createAssistantMessage({ + content: [], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: empty, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const replacement = createAssistantMessage({ + content: [{ type: 'text', text: 'Model-only replacement' }], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: replacement, + }, { + surfaceOp: { op: 'replace', start: firstEvent.seq, end: firstEvent.seq }, + sourceEventSeqs: [firstEvent.seq], + }) + + return { + userMessageId: user.id, + assistantMessageIds: [first.id, second.id], + emptyAssistantMessageId: empty.id, + replacementAssistantMessageId: replacement.id, + } +} + +/** Construct one cold persistence fixture without publishing a live Session. */ +export function messageFixture( + rawId: string, + options: { readonly createdAt?: number; readonly cwd?: string } = {}, +): MessageFixture { + const id = SessionId(rawId) + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id, + createdAt: options.createdAt ?? 1_700_000_000_000, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + } + const session = Session.create(id, [], header) + return { session, ...appendMessageFixture(session) } +} + +/** Minimal controllable persistence provider for service-level tests. */ +class TestPersistence extends SessionPersistence { + static inject = ['sessions'] + + readonly durable = new Map() + readonly logical = new Map() + inspectFailure: Error | undefined + inspectCalls = 0 + readFromCalls = 0 + onReadFrom: (() => void) | undefined + + locate(_meta: SessionHeader): SessionLocation | undefined { return undefined } + create(_meta: SessionHeader): Promise { return Promise.resolve() } + append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } + + load(id: SessionId): Promise { + return this.readFrom(id, 0) + } + + inspect(id: SessionId): Promise { + this.inspectCalls += 1 + if (this.inspectFailure !== undefined) return Promise.reject(this.inspectFailure) + const explicit = this.logical.get(id) + if (explicit !== undefined) return Promise.resolve(explicit) + const live = this.ctx.sessions.get(id) + if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.events }) + const stored = this.durable.get(id) + return stored === undefined + ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) + : Promise.resolve(stored) + } + + readFrom( + id: SessionId, + fromSeq: number, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + this.readFromCalls += 1 + this.onReadFrom?.() + const stored = this.durable.get(id) + return stored === undefined + ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) + : Promise.resolve({ meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) }) + } + + list(): Promise { + return Promise.resolve([...this.durable.values()].map(value => value.meta)) + } + + listSnapshots(): Promise { + return Promise.resolve([...this.durable.values()].map((value, index) => ({ + header: value.meta, + revision: SessionPersistenceRevision(`test:${index}:${value.events.length}`), + }))) + } + + persist(session: Session): void { + this.durable.set(session.id, { meta: session.header, events: session.events }) + } + + setDurable(inspection: SessionInspection): void { + this.durable.set(inspection.meta.id, inspection) + } +} + +export interface TestHarness { + readonly ctx: Context + readonly persistence: TestPersistence + readonly root: string + dispose(): Promise +} + +/** Compose the service over the real storage hub/domain/JSON backend. */ +export async function setupHarness(maxNoteBytes = 64): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-')) + const ctx = new Context() + try { + await ctx.plugin(SessionStore) + await ctx.plugin(TestPersistence) + await ctx.plugin(Storage) + await ctx.plugin(StorageJson, { root }) + await ctx.plugin(StorageDomain, { backend: 'json' }) + await ctx.plugin(MessageFeedbackService, { maxNoteBytes }) + } catch (error) { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + throw error + } + return { + ctx, + persistence: ctx.sessionPersistence as unknown as TestPersistence, + root, + async dispose() { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + }, + } +} diff --git a/packages/feedback/message-feedback/tests/loader-composition.spec.ts b/packages/feedback/message-feedback/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..98a7f29243 --- /dev/null +++ b/packages/feedback/message-feedback/tests/loader-composition.spec.ts @@ -0,0 +1,115 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import MessageFeedbackService from '../src/index.ts' +import { appendMessageFixture } from './helpers.ts' + +let root: string | undefined +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) +async function loadComposition(configPath: string): Promise { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(root as string).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-persistence-jsonl', SessionPersistenceJsonl], + ['@deepseek-ai/dsh-storage', Storage], + ['@deepseek-ai/dsh-storage-json', StorageJson], + ['@deepseek-ai/dsh-storage-domain', StorageDomain], + ['@deepseek-ai/dsh-message-feedback', MessageFeedbackService], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + const unloaded = [...ctx.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + return ctx +} + +describe('message feedback through a real Loader composition', () => { + it('persists a checkpointed target and its sidecar across a cold restart', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-persistence-jsonl'", + ' config:', + ` root: ${JSON.stringify(join(root, 'sessions'))}`, + ' compression: none', + ' writeBatchMaxDelayMs: 1', + "- name: '@deepseek-ai/dsh-storage'", + "- name: '@deepseek-ai/dsh-storage-json'", + ' config:', + ` root: ${JSON.stringify(join(root, 'storage'))}`, + "- name: '@deepseek-ai/dsh-storage-domain'", + ' config:', + ' backend: json', + "- name: '@deepseek-ai/dsh-message-feedback'", + ' config:', + ' maxNoteBytes: 32', + '', + ].join('\n')) + + const first = await loadComposition(configPath) + expect(first.messageFeedback.typertGateway.namespace).toBe('messageFeedback') + expect(remoteMethods(first.messageFeedback).map(marker => marker.method)) + .toEqual(['list', 'put', 'delete']) + + const session = first.sessions.create(SessionId('loader-feedback'), { + meta: { cwd: root }, + }) + const fixture = appendMessageFixture(session) + const put = await first.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + note: 'survives restart', + ifVersion: null, + }) + if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`) + const durable = await first.sessionPersistence.readFrom(session.id, 0) + expect(durable.events.some(event => + event.type === 'assistant/message' + && event.data.message.id === fixture.assistantMessageIds[0])).toBe(true) + + await first.fiber.dispose() + contexts.splice(contexts.indexOf(first), 1) + + const second = await loadComposition(configPath) + await expect(second.messageFeedback.list({ sessionId: session.id })).resolves.toEqual({ + ok: true, + value: { items: [put.value] }, + }) + expect(second.sessions.get(session.id)).toBeUndefined() + }) +}) diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts new file mode 100644 index 0000000000..881bf9dab5 --- /dev/null +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -0,0 +1,546 @@ +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import MessageFeedbackService, { messageFeedbackRowSchema } from '../src/index.ts' +import type { + MessageFeedbackItem, + MessageFeedbackVersion, +} from '../src/index.ts' +import { + appendMessageFixture, + messageFixture, + setupHarness, + type TestHarness, +} from './helpers.ts' + +const harnesses: TestHarness[] = [] + +async function harness(maxNoteBytes = 64): Promise { + const value = await setupHarness(maxNoteBytes) + harnesses.push(value) + return value +} + +afterEach(async () => { + vi.useRealTimers() + await Promise.all(harnesses.splice(0).map(value => value.dispose())) +}) + +function staleVersion(): MessageFeedbackVersion { + return randomUUID() as MessageFeedbackVersion +} + +function expectItem( + result: Awaited>, +): MessageFeedbackItem { + if (!result.ok) throw new Error(`expected feedback item, got ${result.error.code}`) + return result.value +} + +describe('MessageFeedbackService public contract', () => { + it('publishes the exact Gateway namespace and Remote method names', async () => { + const { ctx } = await harness() + const binding = ctx.messageFeedback.typertGateway + expect(binding.serviceKey).toBe('messageFeedback') + expect(binding.namespace).toBe('messageFeedback') + expect(remoteMethods(ctx.messageFeedback)).toEqual([ + { method: 'list', invocation: { kind: 'direct' } }, + { method: 'put', invocation: { kind: 'direct' } }, + { method: 'delete', invocation: { kind: 'direct' } }, + ]) + }) + + it('returns session-not-found only for a definite persistence miss', async () => { + const { ctx, persistence } = await harness() + const missing = SessionId('missing-session') + await expect(ctx.messageFeedback.list({ sessionId: missing })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + + const fixture = messageFixture('corrupt-session') + persistence.setDurable({ meta: fixture.session.header, events: fixture.session.events }) + const corruption = new Error('stored log checksum mismatch') + persistence.inspectFailure = corruption + await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption) + }) + + it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => { + const { ctx, persistence } = await harness() + const missing = SessionId('missing-mutations') + const missingMessage = 'missing-message' as MessageId + await expect(ctx.messageFeedback.put({ + sessionId: missing, + messageId: missingMessage, + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + await expect(ctx.messageFeedback.delete({ + sessionId: missing, + messageId: missingMessage, + ifVersion: staleVersion(), + })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + + const fixture = messageFixture('absent-version-conflict') + persistence.persist(fixture.session) + const expected = staleVersion() + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: expected, + })).resolves.toEqual({ + ok: false, + error: { + code: 'version-conflict', + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + expected, + actual: null, + }, + }) + }) + + it('creates, updates, and retry-reads immutable items with monotonic Host times', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('timestamps') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + + vi.useFakeTimers() + vi.setSystemTime(1_700_000_001_000) + const created = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: ' exact prose ', + ifVersion: null, + })) + expect(created).toMatchObject({ + messageId, + rating: 'positive', + note: ' exact prose ', + createdAt: 1_700_000_001_000, + updatedAt: 1_700_000_001_000, + }) + expect(created.version).toMatch(/^[0-9a-f-]{36}$/u) + expect(Object.isFrozen(created)).toBe(true) + + vi.setSystemTime(1_700_000_000_000) + const updated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: created.version, + })) + expect(updated).toMatchObject({ + messageId, + rating: 'negative', + createdAt: created.createdAt, + updatedAt: created.updatedAt, + }) + expect(updated.version).not.toBe(created.version) + + const retry = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: null, + })) + expect(retry).toEqual(updated) + + const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id }) + if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`) + expect(listed.value.items).toEqual([updated]) + expect(listed.value.items[0]).not.toBe(updated) + expect(Object.isFrozen(listed.value)).toBe(true) + expect(Object.isFrozen(listed.value.items)).toBe(true) + expect(Object.isFrozen(listed.value.items[0])).toBe(true) + }) + + it('reports non-blank and complete UTF-8 byte limits without touching persistence', async () => { + const { ctx, persistence } = await harness(4) + const fixture = messageFixture('note-limits') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const before = persistence.inspectCalls + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: ' \n\t ', + ifVersion: null, + })).resolves.toEqual({ ok: false, error: { code: 'note-blank' } }) + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: 'ééé', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { code: 'note-too-large', maxBytes: 4, actualBytes: 6 }, + }) + expect(persistence.inspectCalls).toBe(before) + + expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: '😀', + ifVersion: null, + })) + }) + + it('accepts only non-empty append-origin assistant projections as targets', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('targets') + persistence.persist(fixture.session) + const rejectedTargets: MessageId[] = [ + fixture.userMessageId, + fixture.emptyAssistantMessageId, + fixture.replacementAssistantMessageId, + ] + for (const messageId of rejectedTargets) { + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { + code: 'target-not-found', + sessionId: fixture.session.id, + messageId, + }, + }) + } + expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + }) + + it('fails invalid direct configuration and a read before domain initialization', async () => { + const invalidCtx = new Context() + expect(() => new MessageFeedbackService(invalidCtx, { maxNoteBytes: 0 })) + .toThrow(/positive safe integer/u) + await invalidCtx.fiber.dispose() + + const fixture = messageFixture('uninitialized-domain') + const rawCtx = new Context() + rawCtx.provide('sessions', { get: () => undefined } as never) + rawCtx.provide('sessionPersistence', { + listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]), + inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.events }), + } as never) + const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 }) + await expect(raw.list({ sessionId: fixture.session.id })) + .rejects.toThrow(/durable domain is not initialized/u) + await rawCtx.fiber.dispose() + }) + + it('rejects durable rows with duplicate message ids or reused item versions', () => { + const version = staleVersion() + const duplicate = messageFeedbackRowSchema.safeParse({ + session: { createdAt: 1 }, + items: [ + { + messageId: 'same-message', + rating: 'positive', + version, + createdAt: 1, + updatedAt: 1, + }, + { + messageId: 'same-message', + rating: 'negative', + version, + createdAt: 1, + updatedAt: 1, + }, + ], + }) + expect(duplicate.success).toBe(false) + if (duplicate.success) throw new Error('expected duplicate row rejection') + expect(duplicate.error.issues.map(issue => issue.path.join('.'))) + .toEqual(['items.1.messageId', 'items.1.version']) + }) +}) + +describe('MessageFeedbackService item concurrency', () => { + it('serializes whole-row writes while keeping versions independent per message', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('concurrent-items') + persistence.persist(fixture.session) + const [firstId, secondId] = fixture.assistantMessageIds + + const [firstResult, secondResult] = await Promise.all([ + ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'positive', + ifVersion: null, + }), + ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: secondId, + rating: 'negative', + ifVersion: null, + }), + ]) + const first = expectItem(firstResult) + const second = expectItem(secondResult) + const updated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'negative', + note: 'changed', + ifVersion: first.version, + })) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'positive', + note: 'stale change', + ifVersion: first.version, + })).resolves.toEqual({ + ok: false, + error: { + code: 'version-conflict', + sessionId: fixture.session.id, + messageId: firstId, + expected: first.version, + actual: updated.version, + }, + }) + + const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id }) + if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`) + expect(listed.value.items).toEqual([updated, second]) + expect(listed.value.items[1]?.version).toBe(second.version) + }) + + it('makes delete retries stable and prevents delete/recreate ABA', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('delete-aba') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const created = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })) + + await expect(ctx.messageFeedback.delete({ + sessionId: fixture.session.id, + messageId, + ifVersion: staleVersion(), + })).resolves.toMatchObject({ + ok: false, + error: { code: 'version-conflict', actual: created.version }, + }) + const request = { + sessionId: fixture.session.id, + messageId, + ifVersion: created.version, + } + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: true, + value: { absent: true }, + }) + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: true, + value: { absent: true }, + }) + + const recreated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: null, + })) + expect(recreated.version).not.toBe(created.version) + await expect(ctx.messageFeedback.delete(request)).resolves.toMatchObject({ + ok: false, + error: { code: 'version-conflict', actual: recreated.version }, + }) + }) + + it('fences a reused Session id and lets the new lifecycle start cleanly', async () => { + const { ctx, persistence } = await harness() + const old = messageFixture('reused-session', { createdAt: 10, cwd: '/old' }) + persistence.persist(old.session) + const oldItem = expectItem(await ctx.messageFeedback.put({ + sessionId: old.session.id, + messageId: old.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + + const replacement = Session.create( + old.session.id, + old.session.events, + { ...old.session.header, createdAt: 20, cwd: '/new' }, + ) + persistence.persist(replacement) + await expect(ctx.messageFeedback.list({ sessionId: replacement.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + await expect(ctx.messageFeedback.delete({ + sessionId: replacement.id, + messageId: old.assistantMessageIds[0], + ifVersion: oldItem.version, + })).resolves.toEqual({ ok: true, value: { absent: true } }) + + const newItem = expectItem(await ctx.messageFeedback.put({ + sessionId: replacement.id, + messageId: old.assistantMessageIds[0], + rating: 'negative', + ifVersion: null, + })) + expect(newItem.version).not.toBe(oldItem.version) + }) +}) + +describe('MessageFeedbackService durability ordering', () => { + it('rejects a logical target missing from the cold physical durable prefix', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('cold-prefix') + persistence.logical.set(fixture.session.id, { + meta: fixture.session.header, + events: fixture.session.events, + }) + persistence.setDurable({ meta: fixture.session.header, events: [] }) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { + code: 'target-not-found', + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + }, + }) + expect(persistence.readFromCalls).toBe(1) + await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + }) + + it('commits a live target checkpoint before the sidecar write without a cold-log reread', async () => { + const { ctx, persistence } = await harness() + const session = ctx.sessions.create(SessionId('live-checkpoint'), { + meta: { createdAt: 30, cwd: '/live' }, + }) + const fixture = appendMessageFixture(session) + const order: string[] = [] + ctx.on('session/flush', (current) => { + order.push('session:durable') + persistence.persist(current) + }) + ctx.on('domain/changed', (change) => { + if (change.domain === 'message_feedback') order.push('sidecar:durable') + }) + persistence.onReadFrom = () => { order.push('unexpected:cold-read') } + + expectItem(await ctx.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + expect(order).toEqual(['session:durable', 'sidecar:durable']) + expect(persistence.readFromCalls).toBe(0) + expect(persistence.durable.get(session.id)?.events).toContainEqual( + expect.objectContaining({ type: 'assistant/message' }), + ) + }) + + it('fails closed when a live checkpoint fails or has no participant', async () => { + const failed = await harness() + const failedSession = failed.ctx.sessions.create(SessionId('live-flush-failure')) + const failedFixture = appendMessageFixture(failedSession) + const diskFailure = new Error('disk unavailable') + failed.ctx.on('session/flush', () => { throw diskFailure }) + await expect(failed.ctx.messageFeedback.put({ + sessionId: failedSession.id, + messageId: failedFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toBe(diskFailure) + await expect(failed.ctx.messageFeedback.list({ sessionId: failedSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + + const absent = await harness() + const absentSession = absent.ctx.sessions.create(SessionId('live-no-flush')) + const absentFixture = appendMessageFixture(absentSession) + await expect(absent.ctx.messageFeedback.put({ + sessionId: absentSession.id, + messageId: absentFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toThrow(/no durability listener participated/u) + await expect(absent.ctx.messageFeedback.list({ sessionId: absentSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + }) + + it('finishes the captured live checkpoint when the Session detaches mid-flush', async () => { + const { ctx, persistence } = await harness() + const session = ctx.sessions.prepare(SessionId('detach-during-flush'), { + meta: { createdAt: 40, cwd: '/detach' }, + }) + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + const fixture = appendMessageFixture(session) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('session/flush', async (current) => { + started.resolve(undefined) + await release.promise + persistence.persist(current) + }) + + const pending = ctx.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + }) + await started.promise + detach() + expect(ctx.sessions.get(session.id)).toBeUndefined() + release.resolve(undefined) + expectItem(await pending) + expect(persistence.readFromCalls).toBe(0) + await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({ + ok: true, + value: { items: [{ messageId: fixture.assistantMessageIds[0] }] }, + }) + }) +}) diff --git a/packages/feedback/message-feedback/tsconfig.json b/packages/feedback/message-feedback/tsconfig.json new file mode 100644 index 0000000000..e406c4c00e --- /dev/null +++ b/packages/feedback/message-feedback/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session/session-persistence" + }, + { + "path": "../../storage/storage" + }, + { + "path": "../../storage/storage-domain" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 71e3d9a88d..0916b3e669 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -586,6 +586,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'messageFeedback', + summary: 'Storage-domain sidecar service.', + methods: [ + { + signature: '@Remote(\'list\') async list(request: MessageFeedbackListRequest): Promise', + jsDoc: '/**\n * Read feedback belonging to the current persisted Session lifecycle.\n * A stale row from a reused Session id is invisible.\n * @param request - Session identity to inspect and list.\n * @returns current immutable items or `session-not-found`.\n */', + }, + { + signature: '@Remote(\'put\') put(request: MessageFeedbackPutRequest): Promise', + jsDoc: '/**\n * Create or replace feedback for one derived append-origin assistant\n * message. An exact desired-value retry returns the stored item before its\n * stale or `null` version is considered a conflict.\n * @param request - target, desired value, and observed item version.\n * @returns the committed item or an explicit business failure.\n */', + }, + { + signature: '@Remote(\'delete\') delete(request: MessageFeedbackDeleteRequest): Promise', + jsDoc: '/**\n * Delete one feedback item. Absence is successful regardless of the\n * supplied version; an existing item requires an exact version match.\n * @param request - Session, message, and observed item version.\n * @returns the stable absent postcondition, or an explicit failure.\n */', + }, + ], + }, { key: 'permission', summary: 'Owns the deployment\'s permission presets and their write path.', @@ -2331,6 +2349,82 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Message', declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}', }, + { + name: 'MessageFeedbackDeleteRequest', + declaration: 'export interface MessageFeedbackDeleteRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly ifVersion: MessageFeedbackVersion;\n}', + }, + { + name: 'MessageFeedbackDeleteResult', + declaration: 'export type MessageFeedbackDeleteResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackDeleteValue', + declaration: 'export interface MessageFeedbackDeleteValue {\n readonly absent: true;\n}', + }, + { + name: 'MessageFeedbackFailure', + declaration: 'export type MessageFeedbackFailure = MessageFeedbackSessionNotFound | MessageFeedbackTargetNotFound | MessageFeedbackVersionConflict | MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge;', + }, + { + name: 'MessageFeedbackItem', + declaration: 'export interface MessageFeedbackItem {\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly version: MessageFeedbackVersion;\n readonly createdAt: number;\n readonly updatedAt: number;\n}', + }, + { + name: 'MessageFeedbackListRequest', + declaration: 'export interface MessageFeedbackListRequest {\n readonly sessionId: SessionId;\n}', + }, + { + name: 'MessageFeedbackListResult', + declaration: 'export type MessageFeedbackListResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackListValue', + declaration: 'export interface MessageFeedbackListValue {\n readonly items: readonly MessageFeedbackItem[];\n}', + }, + { + name: 'MessageFeedbackNoteBlank', + declaration: 'export interface MessageFeedbackNoteBlank {\n readonly code: \'note-blank\';\n}', + }, + { + name: 'MessageFeedbackNoteTooLarge', + declaration: 'export interface MessageFeedbackNoteTooLarge {\n readonly code: \'note-too-large\';\n readonly maxBytes: number;\n readonly actualBytes: number;\n}', + }, + { + name: 'MessageFeedbackPutRequest', + declaration: 'export interface MessageFeedbackPutRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly ifVersion: MessageFeedbackVersion | null;\n}', + }, + { + name: 'MessageFeedbackPutResult', + declaration: 'export type MessageFeedbackPutResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackRating', + declaration: 'export type MessageFeedbackRating = \'positive\' | \'negative\';', + }, + { + name: 'MessageFeedbackRejected', + declaration: 'export interface MessageFeedbackRejected {\n readonly ok: false;\n readonly error: E;\n}', + }, + { + name: 'MessageFeedbackSessionNotFound', + declaration: 'export interface MessageFeedbackSessionNotFound {\n readonly code: \'session-not-found\';\n readonly sessionId: SessionId;\n}', + }, + { + name: 'MessageFeedbackSuccess', + declaration: 'export interface MessageFeedbackSuccess {\n readonly ok: true;\n readonly value: T;\n}', + }, + { + name: 'MessageFeedbackTargetNotFound', + declaration: 'export interface MessageFeedbackTargetNotFound {\n readonly code: \'target-not-found\';\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n}', + }, + { + name: 'MessageFeedbackVersion', + declaration: 'export type MessageFeedbackVersion = Branded<\'MessageFeedbackVersion\'>;', + }, + { + name: 'MessageFeedbackVersionConflict', + declaration: 'export interface MessageFeedbackVersionConflict {\n readonly code: \'version-conflict\';\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly expected: MessageFeedbackVersion | null;\n readonly actual: MessageFeedbackVersion | null;\n}', + }, { name: 'MessageId', declaration: 'export type MessageId = Branded<\'MessageId\'>;', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 916e5b167b..46d392bc4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1605,6 +1605,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver + '@deepseek-ai/dsh-message-feedback': + specifier: workspace:^ + version: link:../../feedback/message-feedback '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session/session-projection-cache @@ -3792,6 +3795,55 @@ importers: specifier: workspace:^ version: link:../../session/user-id + packages/feedback/message-feedback: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta + packages/fs/fs: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index e31ed08a77..1941e66acb 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -63,6 +63,7 @@ export const SERVICE_PAGE: Record = { httpServer: 'http-server.md', invariants: 'invariants.md', llm: 'llm-streaming.md', + messageFeedback: 'feedback.md', permission: 'permission.md', planMode: 'plan.md', pty: 'pty.md', @@ -229,6 +230,25 @@ export const LINK_MAP: Readonly> = { ResolvedRetryPolicy: 'llm-streaming.md', Message: 'llm-streaming.md', MessageSource: 'llm-streaming.md', + MessageFeedbackDeleteRequest: 'feedback.md', + MessageFeedbackDeleteResult: 'feedback.md', + MessageFeedbackDeleteValue: 'feedback.md', + MessageFeedbackFailure: 'feedback.md', + MessageFeedbackItem: 'feedback.md', + MessageFeedbackListRequest: 'feedback.md', + MessageFeedbackListResult: 'feedback.md', + MessageFeedbackListValue: 'feedback.md', + MessageFeedbackNoteBlank: 'feedback.md', + MessageFeedbackNoteTooLarge: 'feedback.md', + MessageFeedbackPutRequest: 'feedback.md', + MessageFeedbackPutResult: 'feedback.md', + MessageFeedbackRating: 'feedback.md', + MessageFeedbackRejected: 'feedback.md', + MessageFeedbackSessionNotFound: 'feedback.md', + MessageFeedbackSuccess: 'feedback.md', + MessageFeedbackTargetNotFound: 'feedback.md', + MessageFeedbackVersion: 'feedback.md', + MessageFeedbackVersionConflict: 'feedback.md', UserMessage: 'session.md', PreStepDecision: 'core.md', PreStepContext: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 699bcae707..5b950782b1 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -135,7 +135,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session', title: 'In-memory session store', mode: 'core', - consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'], + consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, { @@ -167,7 +167,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable session persistence seam', mode: 'seam', implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], - consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'], + consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, { @@ -211,9 +211,16 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'storage-domain', title: 'Domain data facility', mode: 'core', - consumers: ['workspace'], + consumers: ['workspace', 'message-feedback'], note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.', }, + { + key: 'messageFeedback', + pkg: 'message-feedback', + title: 'Lifecycle-bound message feedback', + mode: 'core', + note: 'Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry.', + }, { key: 'workspace', pkg: 'workspace', diff --git a/tsconfig.host.json b/tsconfig.host.json index 12c5d365d6..c616cc16aa 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -134,6 +134,7 @@ { "path": "./packages/storage/storage-json" }, { "path": "./packages/storage/storage-sqlite" }, { "path": "./packages/storage/storage-domain" }, + { "path": "./packages/feedback/message-feedback" }, { "path": "./packages/workspace/workspace" }, { "path": "./packages/session/session-title" }, { "path": "./packages/session/session-title-llm" }, From c3d0fe1bf99d2b3111b2098ad7b9e029fdf69299 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:51:11 -0700 Subject: [PATCH 74/77] fix(feedback): harden backend consistency --- ...6-08-10-message-feedback-sidecar.i18n.yaml | 4 +- .../2026-08-10-message-feedback-sidecar.md | 4 +- .../2026-08-10-message-feedback-sidecar.zh.md | 4 +- docs/subsystems/feedback.i18n.yaml | 4 +- docs/subsystems/feedback.md | 8 +- docs/subsystems/feedback.zh.md | 8 +- .../message-feedback/README.i18n.yaml | 4 +- packages/feedback/message-feedback/README.md | 8 +- .../feedback/message-feedback/README.zh.md | 8 +- .../feedback/message-feedback/package.json | 2 +- .../feedback/message-feedback/src/index.ts | 29 ++--- .../feedback/message-feedback/src/types.ts | 8 +- .../message-feedback/tests/helpers.ts | 8 +- .../tests/message-feedback.spec.ts | 108 ++++++++++++++---- .../tool-cordis/src/api-catalog.ts | 4 +- 15 files changed, 131 insertions(+), 80 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml index 25457df431..ec3d38f09c 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.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-10-message-feedback-sidecar.md -2026-08-10-message-feedback-sidecar.md: 2c6fe72174e4683793980727da672948793eb45f -2026-08-10-message-feedback-sidecar.zh.md: ee67d0d694e0502f4f4770484d9505eaa43ad232 +2026-08-10-message-feedback-sidecar.md: a741a7e6b9fe422c9aafb68d9b099466399e1ee9 +2026-08-10-message-feedback-sidecar.zh.md: da176e13586bb93ebadcefc0afb44c21ce386e08 diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md index 2c6fe72174..a741a7e6b9 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md @@ -18,9 +18,9 @@ Every usable row is bound to the inspected Session header identity `{createdAt, `put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization. -Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold Session is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or cold physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references. +Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references. -Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. An exact retry of an already committed desired value returns that stored item before a stale `ifVersion` becomes a conflict, preserving its version and timestamps; a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize. +Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize. A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection. diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md index ee67d0d694..da176e1358 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md @@ -18,9 +18,9 @@ Status: implemented `put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。 -`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint;已进入目录的 cold Session 则通过 `SessionPersistence.readFrom` 从序列零做物理复读。随后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或 cold 物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。 +`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。 -每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。对已提交目标值的完全相同重试,会在陈旧 `ifVersion` 形成冲突前返回已存条目,并保留其 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。 +每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。 按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。 diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml index 2dd07e8f8c..681e3c2c60 100644 --- a/docs/subsystems/feedback.i18n.yaml +++ b/docs/subsystems/feedback.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/feedback.md -feedback.md: 4de02753ef179b13cae90b51ba965c42e2f519ee -feedback.zh.md: 88e51cdb5c07e8dd7499f0ac2449b6b6ecc3388d +feedback.md: e5c99ee639403ec3da0cf5845ec00f5ea1a5817d +feedback.zh.md: 407d111da59af1d9a5c99c0e3d06d70df9051a13 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md index 4de02753ef..e5c99ee639 100644 --- a/docs/subsystems/feedback.md +++ b/docs/subsystems/feedback.md @@ -10,7 +10,7 @@ Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedb One Session sidecar row contains its header identity `{createdAt, cwd}` and feedback items keyed by `MessageId`. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them. -`put` is optimistic and retry-safe. An exact retry of the already stored desired value returns that item before a stale `ifVersion` is treated as a conflict. Deleting an already absent item also succeeds. A per-Session queue encloses inspection, read, conflict evaluation, and whole-row write, so these guarantees cover concurrent calls in one Host process. +`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op. A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue encloses inspection, read, conflict evaluation, and whole-row write, so these guarantees cover concurrent calls in one Host process. ## Target and lifecycle authority @@ -20,7 +20,7 @@ The stored `{createdAt, cwd}` identity must match the inspected header. A mismat ## Persistence and Remote contract -The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold target is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority. +The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority. ## Boundaries and limitations @@ -56,8 +56,8 @@ Storage-domain sidecar service. It inspects persisted Session history and never /** * Create or replace feedback for one derived append-origin assistant - * message. An exact desired-value retry returns the stored item before its - * stale or `null` version is considered a conflict. + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. * @param request - target, desired value, and observed item version. * @returns the committed item or an explicit business failure. */ diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md index 88e51cdb5c..407d111da5 100644 --- a/docs/subsystems/feedback.zh.md +++ b/docs/subsystems/feedback.zh.md @@ -10,7 +10,7 @@ 每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。 -`put` 采用乐观并发并可安全重试。若目标值已经存储,完全相同的重试会先返回该条目,再考虑陈旧 `ifVersion` 是否构成冲突。删除已经不存在的条目也同样成功。按 Session 划分的队列覆盖检查、读取、冲突判断与整行写入,因此这些保证适用于单个 Host 进程中的并发调用。 +`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列覆盖检查、读取、冲突判断与整行写入,因此这些保证适用于单个 Host 进程中的并发调用。 ## 目标与生命周期权威 @@ -20,7 +20,7 @@ ## 持久化与 Remote 契约 -服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;已进入目录的 cold 目标则通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。 +服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。 ## 边界与限制 @@ -56,8 +56,8 @@ Storage-domain sidecar service. It inspects persisted Session history and never /** * Create or replace feedback for one derived append-origin assistant - * message. An exact desired-value retry returns the stored item before its - * stale or `null` version is considered a conflict. + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. * @param request - target, desired value, and observed item version. * @returns the committed item or an explicit business failure. */ diff --git a/packages/feedback/message-feedback/README.i18n.yaml b/packages/feedback/message-feedback/README.i18n.yaml index 2ff326dc3a..97cd37d0b4 100644 --- a/packages/feedback/message-feedback/README.i18n.yaml +++ b/packages/feedback/message-feedback/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/feedback/message-feedback/README.md -README.md: 0fda4fb535e5186c252377383520671cd574b364 -README.zh.md: 151db5c10048e6ff32d3f7a4ddbcba986d87597a +README.md: 948cdcd2ef0fa2d1bcea68fdc40f16be08e963a8 +README.zh.md: 14e62eea0a182bc60be2c370653d1cb031d6b8a8 diff --git a/packages/feedback/message-feedback/README.md b/packages/feedback/message-feedback/README.md index 0fda4fb535..948cdcd2ef 100644 --- a/packages/feedback/message-feedback/README.md +++ b/packages/feedback/message-feedback/README.md @@ -31,7 +31,7 @@ Each stored row carries the inspected Session header identity `{createdAt, cwd}` `SessionPersistence.inspect()` supplies a cold-safe observation without publishing or resuming an Agent and without committing cold repair. For a Session without a live owner, `listSnapshots()` first decides definite absence; an `inspect()` failure for a catalogued Session remains an infrastructure failure rather than being guessed into `session-not-found`. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin messages, empty usage-only assistant records, and non-assistant records return `target-not-found`. -After initial validation, `put` establishes a durability barrier before writing the sidecar. A matching live Session commits through the canonical `ctx.sessions.flush` checkpoint; a catalogued cold Session is physically re-read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are validated again. A missing flush participant, changed identity, vanished target, or cold physical-read failure prevents the sidecar commit, so durable feedback never precedes the durable target message. +After initial validation, `put` establishes a durability barrier before writing the sidecar. A matching live Session commits through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are validated again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar commit, so durable feedback never precedes the durable target message. Message feedback is not Session-log content or a Session projection. It emits no `feedback/record` event, does not enter model history, and does not trigger `FEEDBACK_ONLY` telemetry release. @@ -45,13 +45,13 @@ The same three `MessageFeedbackService` methods are published by `GatewayService | `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | committed `MessageFeedbackItem` | `session-not-found`, `target-not-found`, `version-conflict`, `note-blank`, `note-too-large` | | `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`, `version-conflict` | -`MessageFeedbackVersionConflict` returns the caller's `expected` token and the current `actual` token, each nullable where absence is meaningful. `MessageFeedbackNoteTooLarge` returns both `maxBytes` and `actualBytes`. The Client Remote aggregate does not mount the generated client contribution yet; Host callers can use the service/Remote contract without that client assembly. +`MessageFeedbackVersionConflict` returns the authoritative `current` item, or `null` when no item exists. This lets a caller reconcile the current rating, note, and version without a second `list` request. `MessageFeedbackNoteTooLarge` returns both `maxBytes` and `actualBytes`. The Client Remote aggregate does not mount the generated client contribution yet; Host callers can use the service/Remote contract without that client assembly. ## Compare-and-set and idempotency -`ifVersion: null` requests creation only; a material update requires the exact current item version. The check is per message rather than per Session, so changing one item does not conflict with another. Every material create or update assigns a fresh opaque UUID token. +`ifVersion: null` requests creation only; every request for an existing item requires its exact current version, including a no-op whose desired value already matches. The check is per message rather than per Session, so changing one item does not conflict with another. Every material create or update assigns a fresh opaque UUID token, preventing stale writes from crossing an ABA value cycle. -An exact desired-value retry is recognized before `ifVersion` comparison. It returns the already stored item with unchanged version and timestamps, so a caller may safely retry after losing a success response even with the now-stale token or original `null`. `delete` ignores `ifVersion` when the item is already absent and always returns the stable `{ absent: true }` postcondition after success. +A matching-version no-op returns the already stored item with unchanged version and timestamps. After a lost success response, a retry with the old token receives `version-conflict.current`; the caller can compare that authoritative item with its desired value without an extra read. `delete` ignores `ifVersion` when the item is already absent and always returns the stable `{ absent: true }` postcondition after success. A per-Session promise queue encloses inspection, durability validation, sidecar read, comparison, and whole-row write. These semantics serialize concurrent mutations through one service instance; storage-domain itself has no cross-process conditional write. diff --git a/packages/feedback/message-feedback/README.zh.md b/packages/feedback/message-feedback/README.zh.md index 151db5c100..14e62eea0a 100644 --- a/packages/feedback/message-feedback/README.zh.md +++ b/packages/feedback/message-feedback/README.zh.md @@ -31,7 +31,7 @@ `SessionPersistence.inspect()` 提供 cold-safe 观测,不发布或恢复 Agent,也不提交 cold repair。对于没有 live owner 的 Session,系统先用 `listSnapshots()` 判定明确不存在;已进入目录的 Session 若 `inspect()` 失败,仍属于基础设施故障,不会被猜测成 `session-not-found`。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin 消息、仅承载 usage 的空 assistant 记录与非 assistant 记录都返回 `target-not-found`。 -初步校验后,`put` 在写入伴随记录前建立 durability barrier。身份匹配的 live Session 通过权威 `ctx.sessions.flush` checkpoint 提交;已进入目录的 cold Session 则通过 `SessionPersistence.readFrom` 从序列零做物理复读。随后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或 cold 物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。 +初步校验后,`put` 在写入伴随记录前建立 durability barrier。身份匹配的 live Session 先通过权威 `ctx.sessions.flush` checkpoint 提交,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。 message feedback 不是 Session 日志内容或 Session 投影。它不发出 `feedback/record` 事件,不进入模型历史,也不触发 `FEEDBACK_ONLY` 遥测释放。 @@ -45,13 +45,13 @@ message feedback 不是 Session 日志内容或 Session 投影。它不发出 `f | `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | 已提交的 `MessageFeedbackItem` | `session-not-found`、`target-not-found`、`version-conflict`、`note-blank`、`note-too-large` | | `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`、`version-conflict` | -`MessageFeedbackVersionConflict` 返回调用方的 `expected` token 与当前 `actual` token;在表示不存在时,两者可以为 null。`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes` 与 `actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。 +`MessageFeedbackVersionConflict` 返回权威 `current` 条目;条目不存在时为 `null`。调用方无需额外执行 `list`,即可协调当前 rating、note 与 version。`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes` 与 `actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。 ## Compare-and-set 与幂等性 -`ifVersion: null` 表示仅当条目不存在时才创建;实质更新要求与当前条目 version 完全一致。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token。 +`ifVersion: null` 表示仅当条目不存在时才创建;已有条目的每次请求都必须与其当前 version 完全一致,即使目标值已经相同、不会产生实质更新。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token,防止陈旧写入穿过 ABA 值循环。 -系统会在比较 `ifVersion` 之前识别与目标值完全相同的重试。它返回已存条目,version 与时间戳均不变,因此调用方在成功响应丢失后,即使用当前已陈旧的 token 或原始 `null` 也可安全重试。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。 +携带匹配 version 的无变化请求会返回已存条目,version 与时间戳均不变。成功响应丢失后,使用旧 token 重试会得到 `version-conflict.current`;调用方无需额外读取,即可把权威当前值与目标值比较。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。 按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。 diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 5a07238ec9..fe0caa18fb 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts index ce2e9d06f3..6468100cc5 100644 --- a/packages/feedback/message-feedback/src/index.ts +++ b/packages/feedback/message-feedback/src/index.ts @@ -192,8 +192,8 @@ export class MessageFeedbackService extends GatewayService { /** * Create or replace feedback for one derived append-origin assistant - * message. An exact desired-value retry returns the stored item before its - * stale or `null` version is considered a conflict. + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. * @param request - target, desired value, and observed item version. * @returns the committed item or an explicit business failure. */ @@ -228,14 +228,14 @@ export class MessageFeedbackService extends GatewayService { const items = current?.items ?? EMPTY_ITEMS const index = items.findIndex(item => item.messageId === request.messageId) const existing = items[index] + if (request.ifVersion !== (existing?.version ?? null)) { + return rejected(this.versionConflict(existing ?? null)) + } if (existing !== undefined && existing.rating === request.rating && existing.note === note.value) { return success(snapshotItem(existing)) } - if (request.ifVersion !== (existing?.version ?? null)) { - return rejected(this.versionConflict(request, existing?.version ?? null)) - } const now = Date.now() const item = snapshotItem({ @@ -278,7 +278,7 @@ export class MessageFeedbackService extends GatewayService { return success(Object.freeze({ absent: true })) } if (request.ifVersion !== existing.version) { - return rejected(this.versionConflict(request, existing.version)) + return rejected(this.versionConflict(existing)) } await table.put( @@ -298,7 +298,8 @@ export class MessageFeedbackService extends GatewayService { private async inspectSession(sessionId: SessionId): Promise { if (this.ctx.sessions.get(sessionId) === undefined) { const snapshots = await this.ctx.sessionPersistence.listSnapshots() - if (!snapshots.some(snapshot => snapshot.header.id === sessionId)) { + if (!snapshots.some(snapshot => snapshot.header.id === sessionId) + && this.ctx.sessions.get(sessionId) === undefined) { return rejected({ code: 'session-not-found', sessionId }) } } @@ -327,7 +328,7 @@ export class MessageFeedbackService extends GatewayService { `message-feedback: no durability listener participated for live session '${inspection.meta.id}'`, ) } - return inspection + return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) } return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) } @@ -343,17 +344,11 @@ export class MessageFeedbackService extends GatewayService { return success(note) } - /** Build a conflict branch without exposing an orderable version. */ - private versionConflict( - request: Pick, - actual: MessageFeedbackVersion | null, - ): MessageFeedbackVersionConflict { + /** Return the authoritative item needed to reconcile one failed comparison. */ + private versionConflict(current: MessageFeedbackItem | null): MessageFeedbackVersionConflict { return { code: 'version-conflict', - sessionId: request.sessionId, - messageId: request.messageId, - expected: request.ifVersion, - actual, + current: current === null ? null : snapshotItem(current), } } diff --git a/packages/feedback/message-feedback/src/types.ts b/packages/feedback/message-feedback/src/types.ts index a802bfc05a..0be57b17b4 100644 --- a/packages/feedback/message-feedback/src/types.ts +++ b/packages/feedback/message-feedback/src/types.ts @@ -89,12 +89,8 @@ export interface MessageFeedbackTargetNotFound { /** A material mutation did not match the addressed item's current version. */ export interface MessageFeedbackVersionConflict { readonly code: 'version-conflict' - readonly sessionId: SessionId - readonly messageId: MessageId - /** Version supplied by the caller (`null` means create-only). */ - readonly expected: MessageFeedbackVersion | null - /** Current version, or `null` when the item does not exist. */ - readonly actual: MessageFeedbackVersion | null + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null } /** A supplied note contains no non-whitespace character. */ diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index fdb7ebe3b2..c0c327500b 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -117,6 +117,7 @@ class TestPersistence extends SessionPersistence { inspectCalls = 0 readFromCalls = 0 onReadFrom: (() => void) | undefined + onListSnapshots: (() => void | Promise) | undefined locate(_meta: SessionHeader): SessionLocation | undefined { return undefined } create(_meta: SessionHeader): Promise { return Promise.resolve() } @@ -155,11 +156,12 @@ class TestPersistence extends SessionPersistence { return Promise.resolve([...this.durable.values()].map(value => value.meta)) } - listSnapshots(): Promise { - return Promise.resolve([...this.durable.values()].map((value, index) => ({ + async listSnapshots(): Promise { + await this.onListSnapshots?.() + return [...this.durable.values()].map((value, index) => ({ header: value.meta, revision: SessionPersistenceRevision(`test:${index}:${value.events.length}`), - }))) + })) } persist(session: Session): void { diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts index 881bf9dab5..d65000a682 100644 --- a/packages/feedback/message-feedback/tests/message-feedback.spec.ts +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -68,6 +68,25 @@ describe('MessageFeedbackService public contract', () => { await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption) }) + it('rechecks live ownership before returning a cold catalog miss', async () => { + const { ctx, persistence } = await harness() + const sessionId = SessionId('catalog-live-race') + const listed = Promise.withResolvers() + const release = Promise.withResolvers() + persistence.onListSnapshots = async () => { + listed.resolve(undefined) + await release.promise + } + + const pending = ctx.messageFeedback.list({ sessionId }) + await listed.promise + ctx.sessions.create(sessionId, { meta: { createdAt: 1_700_000_000_001 } }) + release.resolve(undefined) + + await expect(pending).resolves.toEqual({ ok: true, value: { items: [] } }) + expect(persistence.inspectCalls).toBe(1) + }) + it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => { const { ctx, persistence } = await harness() const missing = SessionId('missing-mutations') @@ -100,13 +119,7 @@ describe('MessageFeedbackService public contract', () => { ifVersion: expected, })).resolves.toEqual({ ok: false, - error: { - code: 'version-conflict', - sessionId: fixture.session.id, - messageId: fixture.assistantMessageIds[0], - expected, - actual: null, - }, + error: { code: 'version-conflict', current: null }, }) }) @@ -154,7 +167,7 @@ describe('MessageFeedbackService public contract', () => { sessionId: fixture.session.id, messageId, rating: 'negative', - ifVersion: null, + ifVersion: updated.version, })) expect(retry).toEqual(updated) @@ -320,13 +333,7 @@ describe('MessageFeedbackService item concurrency', () => { ifVersion: first.version, })).resolves.toEqual({ ok: false, - error: { - code: 'version-conflict', - sessionId: fixture.session.id, - messageId: firstId, - expected: first.version, - actual: updated.version, - }, + error: { code: 'version-conflict', current: updated }, }) const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id }) @@ -335,6 +342,41 @@ describe('MessageFeedbackService item concurrency', () => { expect(listed.value.items[1]?.version).toBe(second.version) }) + it('rejects a stale put even when the current value has returned to the same state', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('put-aba') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const first = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })) + const second = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: first.version, + })) + const current = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: second.version, + })) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: first.version, + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current }, + }) + }) + it('makes delete retries stable and prevents delete/recreate ABA', async () => { const { ctx, persistence } = await harness() const fixture = messageFixture('delete-aba') @@ -351,9 +393,9 @@ describe('MessageFeedbackService item concurrency', () => { sessionId: fixture.session.id, messageId, ifVersion: staleVersion(), - })).resolves.toMatchObject({ + })).resolves.toEqual({ ok: false, - error: { code: 'version-conflict', actual: created.version }, + error: { code: 'version-conflict', current: created }, }) const request = { sessionId: fixture.session.id, @@ -376,9 +418,9 @@ describe('MessageFeedbackService item concurrency', () => { ifVersion: null, })) expect(recreated.version).not.toBe(created.version) - await expect(ctx.messageFeedback.delete(request)).resolves.toMatchObject({ + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ ok: false, - error: { code: 'version-conflict', actual: recreated.version }, + error: { code: 'version-conflict', current: recreated }, }) }) @@ -449,7 +491,7 @@ describe('MessageFeedbackService durability ordering', () => { }) }) - it('commits a live target checkpoint before the sidecar write without a cold-log reread', async () => { + it('commits and physically verifies a live target checkpoint before the sidecar write', async () => { const { ctx, persistence } = await harness() const session = ctx.sessions.create(SessionId('live-checkpoint'), { meta: { createdAt: 30, cwd: '/live' }, @@ -463,7 +505,7 @@ describe('MessageFeedbackService durability ordering', () => { ctx.on('domain/changed', (change) => { if (change.domain === 'message_feedback') order.push('sidecar:durable') }) - persistence.onReadFrom = () => { order.push('unexpected:cold-read') } + persistence.onReadFrom = () => { order.push('session:verified') } expectItem(await ctx.messageFeedback.put({ sessionId: session.id, @@ -471,14 +513,14 @@ describe('MessageFeedbackService durability ordering', () => { rating: 'positive', ifVersion: null, })) - expect(order).toEqual(['session:durable', 'sidecar:durable']) - expect(persistence.readFromCalls).toBe(0) + expect(order).toEqual(['session:durable', 'session:verified', 'sidecar:durable']) + expect(persistence.readFromCalls).toBe(1) expect(persistence.durable.get(session.id)?.events).toContainEqual( expect.objectContaining({ type: 'assistant/message' }), ) }) - it('fails closed when a live checkpoint fails or has no participant', async () => { + it('fails closed when a live checkpoint fails, has no participant, or is not physically durable', async () => { const failed = await harness() const failedSession = failed.ctx.sessions.create(SessionId('live-flush-failure')) const failedFixture = appendMessageFixture(failedSession) @@ -508,6 +550,22 @@ describe('MessageFeedbackService durability ordering', () => { ok: true, value: { items: [] }, }) + + const noDurability = await harness() + const unpersistedSession = noDurability.ctx.sessions.create(SessionId('live-unpersisted')) + const unpersistedFixture = appendMessageFixture(unpersistedSession) + noDurability.ctx.on('session/flush', () => {}) + await expect(noDurability.ctx.messageFeedback.put({ + sessionId: unpersistedSession.id, + messageId: unpersistedFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toThrow(/not found/u) + expect(noDurability.persistence.durable.has(unpersistedSession.id)).toBe(false) + await expect(noDurability.ctx.messageFeedback.list({ sessionId: unpersistedSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) }) it('finishes the captured live checkpoint when the Session detaches mid-flush', async () => { @@ -537,7 +595,7 @@ describe('MessageFeedbackService durability ordering', () => { expect(ctx.sessions.get(session.id)).toBeUndefined() release.resolve(undefined) expectItem(await pending) - expect(persistence.readFromCalls).toBe(0) + expect(persistence.readFromCalls).toBe(1) await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({ ok: true, value: { items: [{ messageId: fixture.assistantMessageIds[0] }] }, diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 0916b3e669..f3847e15ff 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -596,7 +596,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'put\') put(request: MessageFeedbackPutRequest): Promise', - jsDoc: '/**\n * Create or replace feedback for one derived append-origin assistant\n * message. An exact desired-value retry returns the stored item before its\n * stale or `null` version is considered a conflict.\n * @param request - target, desired value, and observed item version.\n * @returns the committed item or an explicit business failure.\n */', + jsDoc: '/**\n * Create or replace feedback for one derived append-origin assistant\n * message. Every request must match the addressed item\'s current version;\n * a matching no-op returns the stored item without changing its revision.\n * @param request - target, desired value, and observed item version.\n * @returns the committed item or an explicit business failure.\n */', }, { signature: '@Remote(\'delete\') delete(request: MessageFeedbackDeleteRequest): Promise', @@ -2423,7 +2423,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'MessageFeedbackVersionConflict', - declaration: 'export interface MessageFeedbackVersionConflict {\n readonly code: \'version-conflict\';\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly expected: MessageFeedbackVersion | null;\n readonly actual: MessageFeedbackVersion | null;\n}', + declaration: 'export interface MessageFeedbackVersionConflict {\n readonly code: \'version-conflict\';\n readonly current: MessageFeedbackItem | null;\n}', }, { name: 'MessageId', From c61be607373f58aeac1e75a7e7a48bafd739620c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:18:45 -0700 Subject: [PATCH 75/77] fix(feedback): address backend review gaps --- ...6-08-10-message-feedback-sidecar.i18n.yaml | 4 +- .../2026-08-10-message-feedback-sidecar.md | 2 +- .../2026-08-10-message-feedback-sidecar.zh.md | 2 +- .../message-feedback-protocol.snapshot.ts | 115 ++++++++++ .../protocol.expected.json | 203 ++++++++++++++++++ .../message-feedback-protocol/session.jsonl | 7 + apps/web/tsconfig.json | 1 + docs/subsystems/feedback.i18n.yaml | 4 +- docs/subsystems/feedback.md | 180 ++++++++++++++++ docs/subsystems/feedback.zh.md | 180 ++++++++++++++++ .../message-feedback/README.i18n.yaml | 4 +- packages/feedback/message-feedback/README.md | 5 +- .../feedback/message-feedback/README.zh.md | 5 +- .../feedback/message-feedback/src/index.ts | 10 +- .../feedback/message-feedback/src/spec.ts | 2 + .../message-feedback/tests/helpers.ts | 15 +- .../message-feedback/tests/invariant.spec.ts | 23 ++ .../tests/message-feedback.spec.ts | 51 +++++ scripts/type-equiv.manifest.json | 95 ++++++++ tsconfig.host.json | 1 + 20 files changed, 893 insertions(+), 16 deletions(-) create mode 100644 apps/web/tests/message-feedback-protocol.snapshot.ts create mode 100644 apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json create mode 100644 apps/web/tests/snapshots/message-feedback-protocol/session.jsonl create mode 100644 packages/feedback/message-feedback/tests/invariant.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml index ec3d38f09c..20b1a4cfd5 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.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-10-message-feedback-sidecar.md -2026-08-10-message-feedback-sidecar.md: a741a7e6b9fe422c9aafb68d9b099466399e1ee9 -2026-08-10-message-feedback-sidecar.zh.md: da176e13586bb93ebadcefc0afb44c21ce386e08 +2026-08-10-message-feedback-sidecar.md: 780cbaa840fcac7bcfa799468bfd61f5b08715cb +2026-08-10-message-feedback-sidecar.zh.md: 72ecc82717010f65d013418a45c3b38c6084aaf9 diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md index a741a7e6b9..780cbaa840 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md @@ -22,7 +22,7 @@ Before `put` commits a sidecar row, it puts the target log behind a durability b Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize. -A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection. +A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. Plugin disposal closes admission, drains accepted queue work, and then closes the storage domain. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection. `maxNoteBytes` is a required deployment choice and bounds the UTF-8 byte length of an optional note; the Web Host bundle sets it explicitly to `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` contract directly through `GatewayService` and `@Remote`. Client Remote aggregate mounting and UI remain separately owned and deferred; their later adapter stays a thin consumer of this Host contract. diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md index da176e1358..72ecc82717 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md @@ -22,7 +22,7 @@ Status: implemented 每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。 -按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。 +按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。Plugin disposal 会关闭接纳、排空已进入队列的工作,然后关闭 storage domain。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。 `maxNoteBytes` 是必填的部署选择,用于限制可选备注的 UTF-8 字节长度;Web Host bundle 将其显式设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 直接发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 契约。客户端 Remote 聚合挂载与 UI 由各自边界负责并保持延后;后续适配层只是该 Host 契约的薄消费者。 diff --git a/apps/web/tests/message-feedback-protocol.snapshot.ts b/apps/web/tests/message-feedback-protocol.snapshot.ts new file mode 100644 index 0000000000..9fbc556f77 --- /dev/null +++ b/apps/web/tests/message-feedback-protocol.snapshot.ts @@ -0,0 +1,115 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + assertFixtureInventory, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + type WebScaffold, +} from './scaffold.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url)) +const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json') +const SESSION_ID = 'message-feedback-protocol' +const MESSAGE_ID = '11111111-1111-4111-8111-111111111111' + +interface ProtocolExchange { + readonly endpoint: string + readonly request: unknown + readonly status: number + readonly response: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */ +function createdVersion(response: unknown): string { + if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true + || !isRecord(response.result.value) || response.result.value.ok !== true + || !isRecord(response.result.value.value) + || typeof response.result.value.value.version !== 'string') { + throw new Error('messageFeedback.put did not return a successful versioned item') + } + return response.result.value.value.version +} + +/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */ +function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string { + return JSON.stringify(exchanges, (key, value: unknown) => { + if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}' + if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}' + return value + }, 2) +} + +describe('message feedback Host Remote protocol', () => { + let scaffold: WebScaffold + + beforeAll(async () => { + scaffold = await launchWebScaffold() + await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID) + }) + + afterAll(async () => { + await scaffold?.close() + }) + + it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => { + const exchanges: ProtocolExchange[] = [] + const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise => { + const payload = { args: { request } } + const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId, + method: endpoint, + payload, + }), + }) + const body: unknown = await response.json() + exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body }) + return body + } + + await invoke('feedback-invalid', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'invalid-rating', + ifVersion: null, + }) + await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID }) + const created = await invoke('feedback-put', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'positive', + note: 'Useful answer', + ifVersion: null, + }) + const version = createdVersion(created) + expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID }) + await invoke('feedback-conflict', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'negative', + ifVersion: null, + }) + await invoke('feedback-delete', 'messageFeedback/delete', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + ifVersion: version, + }) + await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID }) + + expect(exchanges.every(exchange => exchange.status === 200)).toBe(true) + await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode) + await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl']) + }) +}) diff --git a/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json b/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json new file mode 100644 index 0000000000..302e4876df --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json @@ -0,0 +1,203 @@ +[ + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "invalid-rating", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-invalid", + "result": { + "ok": false, + "error": { + "code": "internal", + "message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation", + "details": {} + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-empty", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [] + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-put", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-created", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [ + { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + ] + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "negative", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-conflict", + "result": { + "ok": true, + "value": { + "ok": false, + "error": { + "code": "version-conflict", + "current": { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/delete", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "ifVersion": "{{version}}" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-delete", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "absent": true + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-deleted", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [] + } + } + } + } + } +] diff --git a/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl b/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl new file mode 100644 index 0000000000..d970fb8cb4 --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl @@ -0,0 +1,7 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786406400000,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1786406400001,"data":{"turn":1}} +{"type":"user/message","seq":1,"time":1786406400002,"data":{"role":"user","content":[{"type":"text","text":"Give one useful answer."}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1786406400003,"data":{"turn":1,"step":1}} +{"type":"assistant/message","seq":3,"time":1786406400004,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"A useful answer."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"11111111-1111-4111-8111-111111111111"},"usage":{"inputTokens":4,"outputTokens":4}},"surfaceOp":"append"} +{"type":"step/end","seq":4,"time":1786406400005,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":5,"time":1786406400006,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index fc1a777afc..8147982475 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -25,6 +25,7 @@ "tests/scaffold.ts", "tests/scaffold-hermetic.e2e.ts", "tests/minimal-preset.snapshot.ts", + "tests/message-feedback-protocol.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml index 681e3c2c60..f6de2debe9 100644 --- a/docs/subsystems/feedback.i18n.yaml +++ b/docs/subsystems/feedback.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/feedback.md -feedback.md: e5c99ee639403ec3da0cf5845ec00f5ea1a5817d -feedback.zh.md: 407d111da59af1d9a5c99c0e3d06d70df9051a13 +feedback.md: 76a29f7d6ba604fa07ed56429c9b066e22639671 +feedback.zh.md: 5a409832de68b6d0bc9688a907c0f22edd3b0a43 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md index e5c99ee639..76a29f7d6b 100644 --- a/docs/subsystems/feedback.md +++ b/docs/subsystems/feedback.md @@ -6,6 +6,183 @@ English | [中文](feedback.zh.md) Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) +## Public types + +```ts type-equiv +/** Opaque compare-and-set token for one exact feedback item revision. */ +type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> +``` + +```ts type-equiv +/** The human's overall judgment of one assistant message. */ +type MessageFeedbackRating = 'positive' | 'negative' +``` + +```ts type-equiv +/** One current feedback value and its opaque mutation token. */ +interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Read all message feedback belonging to one persisted Session lifecycle. */ +interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Current feedback values for one Session, in first-creation order. */ +interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} +``` + +```ts type-equiv +/** Create or replace feedback for one assistant message. */ +interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} +``` + +```ts type-equiv +/** Delete feedback for one message after observing its current version. */ +interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} +``` + +```ts type-equiv +/** Idempotent deletion acknowledgement. */ +interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} +``` + +```ts type-equiv +/** No persisted Session header exists for the requested id. */ +interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** The id does not name a derived, append-origin assistant message. */ +interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} +``` + +```ts type-equiv +/** A material mutation did not match the addressed item's current version. */ +interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} +``` + +```ts type-equiv +/** A supplied note contains no non-whitespace character. */ +interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} +``` + +```ts type-equiv +/** A supplied note exceeds the configured UTF-8 byte limit. */ +interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} +``` + +```ts type-equiv +/** Failures shared by the public message-feedback operations. */ +type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge +``` + +```ts type-equiv +/** Successful public operation result. */ +interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} +``` + +```ts type-equiv +/** Rejected public operation result with a stable business failure. */ +interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} +``` + +```ts type-equiv +/** Result returned by the message-feedback `list` operation. */ +type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +```ts type-equiv +/** Result returned by the message-feedback `put` operation. */ +type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > +``` + +```ts type-equiv +/** Result returned by the message-feedback `delete` operation. */ +type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + ## Data and concurrency One Session sidecar row contains its header identity `{createdAt, cwd}` and feedback items keyed by `MessageId`. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them. @@ -22,12 +199,15 @@ The stored `{createdAt, cwd}` identity must match the inspected header. A mismat The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority. +Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain. + ## Boundaries and limitations - The client Remote aggregate mount and UI consumer are separately owned and deferred. - The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee. - Session persistence has no durable deletion surface. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal. - A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. +- Cold requests scan the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. One Session row also has no item-count or aggregate-byte cap; `maxNoteBytes` bounds only each note until a concrete consumer owns a row policy. - Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract. - The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary. diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md index 407d111da5..5a409832de 100644 --- a/docs/subsystems/feedback.zh.md +++ b/docs/subsystems/feedback.zh.md @@ -6,6 +6,183 @@ 来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) +## 公开类型 + +```ts type-equiv +/** Opaque compare-and-set token for one exact feedback item revision. */ +type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> +``` + +```ts type-equiv +/** The human's overall judgment of one assistant message. */ +type MessageFeedbackRating = 'positive' | 'negative' +``` + +```ts type-equiv +/** One current feedback value and its opaque mutation token. */ +interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Read all message feedback belonging to one persisted Session lifecycle. */ +interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Current feedback values for one Session, in first-creation order. */ +interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} +``` + +```ts type-equiv +/** Create or replace feedback for one assistant message. */ +interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} +``` + +```ts type-equiv +/** Delete feedback for one message after observing its current version. */ +interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} +``` + +```ts type-equiv +/** Idempotent deletion acknowledgement. */ +interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} +``` + +```ts type-equiv +/** No persisted Session header exists for the requested id. */ +interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** The id does not name a derived, append-origin assistant message. */ +interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} +``` + +```ts type-equiv +/** A material mutation did not match the addressed item's current version. */ +interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} +``` + +```ts type-equiv +/** A supplied note contains no non-whitespace character. */ +interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} +``` + +```ts type-equiv +/** A supplied note exceeds the configured UTF-8 byte limit. */ +interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} +``` + +```ts type-equiv +/** Failures shared by the public message-feedback operations. */ +type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge +``` + +```ts type-equiv +/** Successful public operation result. */ +interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} +``` + +```ts type-equiv +/** Rejected public operation result with a stable business failure. */ +interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} +``` + +```ts type-equiv +/** Result returned by the message-feedback `list` operation. */ +type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +```ts type-equiv +/** Result returned by the message-feedback `put` operation. */ +type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > +``` + +```ts type-equiv +/** Result returned by the message-feedback `delete` operation. */ +type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + ## 数据与并发 每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。 @@ -22,12 +199,15 @@ 服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。 +Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。 + ## 边界与限制 - 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。 - 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。 - Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。 - 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 +- 由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。单个 Session 行也没有条目数或聚合字节上限;在具体消费方拥有行策略之前,`maxNoteBytes` 只限制每条备注。 - 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。 - Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。 diff --git a/packages/feedback/message-feedback/README.i18n.yaml b/packages/feedback/message-feedback/README.i18n.yaml index 97cd37d0b4..edf8ec947b 100644 --- a/packages/feedback/message-feedback/README.i18n.yaml +++ b/packages/feedback/message-feedback/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/feedback/message-feedback/README.md -README.md: 948cdcd2ef0fa2d1bcea68fdc40f16be08e963a8 -README.zh.md: 14e62eea0a182bc60be2c370653d1cb031d6b8a8 +README.md: a9ebad25907e32435c8d2a65eb4b2cd9eaa89eeb +README.zh.md: 29cbee0c1ec2ee810948d895c762d2e6320e9b66 diff --git a/packages/feedback/message-feedback/README.md b/packages/feedback/message-feedback/README.md index 948cdcd2ef..a9ebad2590 100644 --- a/packages/feedback/message-feedback/README.md +++ b/packages/feedback/message-feedback/README.md @@ -12,7 +12,7 @@ Public request, value, version, and failure types are exported from the package |---|---| | `maxNoteBytes` | Required positive safe integer: maximum UTF-8 byte length of one optional note. | -Notes must contain at least one non-whitespace character, but accepted text is stored verbatim rather than trimmed. Omitting `note` means the desired value has no note, so an authorized material `put` clears an existing note. +Notes must contain at least one non-whitespace character, but accepted text is stored verbatim rather than trimmed. Omitting `note` means the desired value has no note, so a version-matched material `put` clears an existing note. Note validation precedes Session lookup and can therefore return `note-blank` or `note-too-large` for a missing Session without touching persistence. ```yaml - id: message-feedback @@ -55,6 +55,8 @@ A matching-version no-op returns the already stored item with unchanged version A per-Session promise queue encloses inspection, durability validation, sidecar read, comparison, and whole-row write. These semantics serialize concurrent mutations through one service instance; storage-domain itself has no cross-process conditional write. +Plugin disposal closes mutation admission, drains every operation already accepted into the per-Session queues, and only then closes the storage domain. A mutation submitted after disposal begins rejects as a lifecycle failure instead of entering a closing domain. + ## Model Experience ### Local message-feedback state @@ -79,3 +81,4 @@ Independent. Listing or mutating message feedback does not touch a model request - **Detach/catalog retirement window** — a request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. - **Header identity is not a content fingerprint** — `{createdAt, cwd}` detects reuse only when those fields differ; a cloned log retaining the same header identity is indistinguishable. - **Trusted caller boundary** — `list`/`put`/`delete` carry no authenticated actor or audit identity. A deployment must expose the Host gateway only through its trusted or separately authenticated boundary until authorization and attribution are added. +- **Catalog and row bounds** — a cold request scans the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. `maxNoteBytes` bounds one note, but the item count and aggregate retained bytes of one Session row are not capped; an indexed metadata read and deployment-owned row bound remain deferred until a concrete consumer defines their policy. diff --git a/packages/feedback/message-feedback/README.zh.md b/packages/feedback/message-feedback/README.zh.md index 14e62eea0a..29cbee0c1e 100644 --- a/packages/feedback/message-feedback/README.zh.md +++ b/packages/feedback/message-feedback/README.zh.md @@ -12,7 +12,7 @@ |---|---| | `maxNoteBytes` | 必填正 safe integer:一条可选备注的最大 UTF-8 字节长度。 | -备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此通过授权的实质 `put` 会清除已有备注。 +备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此 version 匹配的实质 `put` 会清除已有备注。备注校验早于 Session 查找,因此即使 Session 不存在,也可能在不访问持久化的情况下返回 `note-blank` 或 `note-too-large`。 ```yaml - id: message-feedback @@ -55,6 +55,8 @@ message feedback 不是 Session 日志内容或 Session 投影。它不发出 `f 按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。 +Plugin disposal 会先关闭变更接纳,排空已进入各个 Session 队列的所有操作,然后才关闭 storage domain。disposal 开始后提交的变更会以生命周期故障拒绝,不会进入正在关闭的 domain。 + ## 模型体验 ### 本地消息反馈状态 @@ -79,3 +81,4 @@ message feedback 不是 Session 日志内容或 Session 投影。它不发出 `f - **Detach/catalog retirement 窗口**——请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 - **Header 身份不是内容指纹**——只有 `{createdAt, cwd}` 不同时才能识别复用;本契约无法区分保留相同 header 身份的克隆日志。 - **调用方边界受信任**——`list`/`put`/`delete` 不携带已认证的 actor 或审计身份。在加入授权与归属信息前,部署方必须只通过受信任或另行认证的边界暴露 Host gateway。 +- **目录与行边界**——由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。`maxNoteBytes` 只限制单条备注,单个 Session 行的条目数和聚合保留字节尚无上限;按索引读取元数据和由部署决定的行边界,延后到具体消费方明确策略时处理。 diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts index 6468100cc5..6126e837e1 100644 --- a/packages/feedback/message-feedback/src/index.ts +++ b/packages/feedback/message-feedback/src/index.ts @@ -158,6 +158,7 @@ export class MessageFeedbackService extends GatewayService { private readonly maxNoteBytes: number private table?: KvTable private readonly operationTails = new Map>() + private mutationAdmissionOpen = true /** * @param ctx - Host context carrying persistence and the storage-domain form. @@ -171,7 +172,11 @@ export class MessageFeedbackService extends GatewayService { /** Open and own the one message-feedback sidecar domain. */ protected async [Service.init](): Promise { const domain = await this.ctx.storageDomain.open(messageFeedbackDomainSpec) - this.ctx.effect(() => () => domain.close(), 'message-feedback.domainClose') + this.ctx.effect(() => async () => { + this.mutationAdmissionOpen = false + await Promise.all(this.operationTails.values()) + await domain.close() + }, 'message-feedback.domainClose') this.table = domain.table('sessions') } @@ -354,6 +359,9 @@ export class MessageFeedbackService extends GatewayService { /** Queue a complete read/compare/write mutation behind this Session's prior mutation. */ private enqueue(sessionId: SessionId, operation: () => Promise): Promise { + if (!this.mutationAdmissionOpen) { + return Promise.reject(new Error('message-feedback: service is disposing')) + } const previous = this.operationTails.get(sessionId) ?? Promise.resolve() const result = previous.then(operation) const tail = result.then(() => undefined, () => undefined) diff --git a/packages/feedback/message-feedback/src/spec.ts b/packages/feedback/message-feedback/src/spec.ts index 5dcefd9cfe..d08a536d06 100644 --- a/packages/feedback/message-feedback/src/spec.ts +++ b/packages/feedback/message-feedback/src/spec.ts @@ -22,6 +22,8 @@ export const messageFeedbackVersionSchema = z.uuid() .transform(value => value as MessageFeedbackVersion) /** Runtime schema for one current feedback item. */ +// Zod infers transformed branded fields structurally, so it cannot name the +// public interface even though every branded output is created below. export const messageFeedbackItemSchema = z.object({ messageId: z.string().min(1).transform(value => value as MessageId), rating: messageFeedbackRatingSchema, diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts index c0c327500b..1dfaa24396 100644 --- a/packages/feedback/message-feedback/tests/helpers.ts +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -116,7 +116,7 @@ class TestPersistence extends SessionPersistence { inspectFailure: Error | undefined inspectCalls = 0 readFromCalls = 0 - onReadFrom: (() => void) | undefined + onReadFrom: (() => void | Promise) | undefined onListSnapshots: (() => void | Promise) | undefined locate(_meta: SessionHeader): SessionLocation | undefined { return undefined } @@ -140,16 +140,16 @@ class TestPersistence extends SessionPersistence { : Promise.resolve(stored) } - readFrom( + async readFrom( id: SessionId, fromSeq: number, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { this.readFromCalls += 1 - this.onReadFrom?.() + await this.onReadFrom?.() const stored = this.durable.get(id) return stored === undefined ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) - : Promise.resolve({ meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) }) + : { meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) } } list(): Promise { @@ -177,6 +177,7 @@ export interface TestHarness { readonly ctx: Context readonly persistence: TestPersistence readonly root: string + disposeFeedback(): Promise dispose(): Promise } @@ -184,22 +185,26 @@ export interface TestHarness { export async function setupHarness(maxNoteBytes = 64): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-')) const ctx = new Context() + let disposeFeedback: (() => Promise) | undefined try { await ctx.plugin(SessionStore) await ctx.plugin(TestPersistence) await ctx.plugin(Storage) await ctx.plugin(StorageJson, { root }) await ctx.plugin(StorageDomain, { backend: 'json' }) - await ctx.plugin(MessageFeedbackService, { maxNoteBytes }) + const feedbackFiber = await ctx.plugin(MessageFeedbackService, { maxNoteBytes }) + disposeFeedback = feedbackFiber.dispose } catch (error) { await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) throw error } + if (disposeFeedback === undefined) throw new Error('message feedback test plugin did not load') return { ctx, persistence: ctx.sessionPersistence as unknown as TestPersistence, root, + disposeFeedback, async dispose() { await ctx.fiber.dispose() await rm(root, { recursive: true, force: true }) diff --git a/packages/feedback/message-feedback/tests/invariant.spec.ts b/packages/feedback/message-feedback/tests/invariant.spec.ts new file mode 100644 index 0000000000..e88131e147 --- /dev/null +++ b/packages/feedback/message-feedback/tests/invariant.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as MessageFeedbackInvariant from '../src/invariant.ts' +import { setupHarness } from './helpers.ts' + +describe('message-feedback invariant companion', () => { + it('removes its registry contribution when its fiber is disposed (HMR safety)', async () => { + const harness = await setupHarness() + try { + await harness.ctx.plugin(InvariantService) + const fiber = await harness.ctx.plugin(MessageFeedbackInvariant) + + expect(() => { + harness.ctx.invariants.register('@deepseek-ai/dsh-message-feedback', () => {}) + }).toThrow(/already registered/u) + + await fiber.dispose() + await expect(harness.ctx.plugin(MessageFeedbackInvariant).await()).resolves.toBeDefined() + } finally { + await harness.dispose() + } + }) +}) diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts index d65000a682..f4478ec202 100644 --- a/packages/feedback/message-feedback/tests/message-feedback.spec.ts +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -459,6 +459,57 @@ describe('MessageFeedbackService item concurrency', () => { })) expect(newItem.version).not.toBe(oldItem.version) }) + + it('drains admitted mutations before domain close and rejects later admission', async () => { + const current = await harness() + const { ctx, persistence } = current + const fixture = messageFixture('dispose-quiescence') + persistence.persist(fixture.session) + const service = ctx.messageFeedback + const lifecycle = service as unknown as { readonly mutationAdmissionOpen: boolean } + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let physicalReads = 0 + let committed = 0 + persistence.onReadFrom = async () => { + physicalReads += 1 + if (physicalReads !== 1) return + started.resolve(undefined) + await release.promise + } + ctx.on('domain/changed', (change) => { + if (change.domain === 'message_feedback') committed += 1 + }) + + const first = service.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + }) + await started.promise + const second = service.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[1], + rating: 'negative', + ifVersion: null, + }) + const disposal = current.disposeFeedback() + await vi.waitFor(() => { expect(lifecycle.mutationAdmissionOpen).toBe(false) }) + + await expect(service.delete({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + ifVersion: staleVersion(), + })).rejects.toThrow('message-feedback: service is disposing') + release.resolve(undefined) + + expectItem(await first) + expectItem(await second) + await disposal + expect(physicalReads).toBe(2) + expect(committed).toBe(2) + }) }) describe('MessageFeedbackService durability ordering', () => { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index c1b1b2753a..3c6ef4e192 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1739,6 +1739,101 @@ "doc": "docs/subsystems/core.md", "symbol": "AgentOptions", "source": "packages/core/agent/src/runtime-types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackVersion", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackRating", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackItem", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackListRequest", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackListValue", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackPutRequest", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackDeleteRequest", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackDeleteValue", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackSessionNotFound", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackTargetNotFound", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackVersionConflict", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackNoteBlank", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackNoteTooLarge", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackFailure", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackSuccess", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackRejected", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackListResult", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackPutResult", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackDeleteResult", + "source": "packages/feedback/message-feedback/src/types.ts" } ] } diff --git a/tsconfig.host.json b/tsconfig.host.json index c616cc16aa..9473e8b923 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -14,6 +14,7 @@ "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", "apps/web/tests/minimal-preset.snapshot.ts", + "apps/web/tests/message-feedback-protocol.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/approval-composer.e2e.ts", From a26d03960aa07defc7009dc8ef33be8ef61857ef Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 11 Aug 2026 11:54:21 +0800 Subject: [PATCH 76/77] feat(subagent): sibling delegations are concurrency-safe One assistant message carrying several subagent calls now overlaps them under the rolling pool (maxParallelToolCalls) instead of serializing each foreground delegation behind an exclusive barrier; results still commit in model order. tool-subagent declares isConcurrencySafe: () => true for every call form: children work in their own sessions, a run never mutates the parent session, and sibling workspace coordination already belongs to the model for background, continuable, and workflow children. The former serial-execution pin flips to a parallel pin plus a gated genuine-overlap test, and the authored subagent-parallel snapshot pins the assembled transcript (tool/call, tool/call, tool/result, tool/result) with interchangeable twin children, which replay race-free under the existing first-call binding and createdAt harvest ordering. The provider seam now requires concurrent starts and continuable preparations for distinct children to isolate operation-local state, cancellation, settlement, and cleanup; a continuable gate test proves a cancelled preparation leaves no Agent or durable Session while its sibling persists independently. Closes deepseek-harness/deepseek-harness#1489 --- ...-10-parallel-tool-call-execution.i18n.yaml | 4 +- ...2026-07-10-parallel-tool-call-execution.md | 2 +- ...6-07-10-parallel-tool-call-execution.zh.md | 2 +- ...09-parallel-subagent-delegations.i18n.yaml | 6 ++ ...026-08-09-parallel-subagent-delegations.md | 47 ++++++++ ...-08-09-parallel-subagent-delegations.zh.md | 47 ++++++++ docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 10 +- docs/subsystems/subagent.zh.md | 10 +- examples/acp-agent/tests/acp.snapshot.ts | 6 ++ .../snapshots/subagent-parallel/input.json | 7 ++ .../subagent-parallel/session.1.jsonl | 18 ++++ .../subagent-parallel/session.2.jsonl | 18 ++++ .../snapshots/subagent-parallel/session.jsonl | 28 +++++ .../subagent-parallel/stdout.expected.jsonl | 4 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/src/types.ts | 10 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 3 + .../tool-subagent/tests/scripted-provider.ts | 7 +- .../tool-subagent/tests/tool-subagent.spec.ts | 101 +++++++++++++++++- 25 files changed, 325 insertions(+), 25 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md create mode 100644 .agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md create mode 100644 examples/acp-agent/tests/snapshots/subagent-parallel/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-parallel/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml index 527dd9bead..830b6467af 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.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-10-parallel-tool-call-execution.md -2026-07-10-parallel-tool-call-execution.md: 830691b7596accd1c5746fc51e38a9022f09e501 -2026-07-10-parallel-tool-call-execution.zh.md: bd336919adbf9b96f64477c1e24964172cde6d92 +2026-07-10-parallel-tool-call-execution.md: 47a95e88c3d0d8bb9e3ddd820b1e63849effd914 +2026-07-10-parallel-tool-call-execution.zh.md: 024e5d4e4cdbf9d1d980bc67db5969fb34941752 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index 830691b759..47a95e88c3 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -60,7 +60,7 @@ Any shared state touched during execution must be concurrency-safe. This include `maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../../docs/config-catalog.md). -The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash has no proven input-sensitive classifier and remains exclusive. +The shipped declarations are conservative. Web search, web fetch, filesystem read, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model ([parallel subagent Agent Note](2026-08-09-parallel-subagent-delegations.md)). Filesystem writes and edits, bash tools, the session-query search tools, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive. Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`. diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md index bd336919ad..024e5d4e4c 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -60,7 +60,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c `maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10`;`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.md)。 -当前实现中的声明保持保守。Web 搜索、Web 获取和文件系统读取选择并行。文件系统写入与编辑、bash 工具、subagent 委派、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。subagent 可能共享父级的工作区或外部资源,而一元分类器无法证明并列委派的作用互不重叠。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 +当前实现中的声明保持保守。Web 搜索、Web 获取、文件系统读取、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责([并行 subagent Agent Note](2026-08-09-parallel-subagent-delegations.md))。文件系统写入与编辑、bash 工具、会话查询的 search 工具、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。 diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.i18n.yaml new file mode 100644 index 0000000000..65e73bdff8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.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-09-parallel-subagent-delegations.md +2026-08-09-parallel-subagent-delegations.md: 01dc2043c9a79666857c4f5aa988fc45dc7cdf56 +2026-08-09-parallel-subagent-delegations.zh.md: 2c2bd700e362000b41adbd1f3cec9ba26ff60b93 diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md new file mode 100644 index 0000000000..01dc2043c9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md @@ -0,0 +1,47 @@ +# Agent Note: Parallel subagent delegations + +Status: implemented + +English | [中文](2026-08-09-parallel-subagent-delegations.zh.md) + +## Problem + +A model that wants fan-out batches several `subagent` calls into one assistant message — that batch is the parallel intent. The delegation tool declared no `isConcurrencySafe` classifier, so the fail-closed scheduler ([parallel tool-call Agent Note](2026-07-10-parallel-tool-call-execution.md)) treated every foreground delegation as an exclusive barrier: nine cards in the GUI, one child running, eight queued behind it for its full runtime. + +The original conservative stance — a unary classifier cannot prove that sibling delegations have disjoint workspace effects — had stopped protecting anything. `run_in_background: true` and continuable delegations already overlap with every later call, including writes; `dsh-workflow-workerthread` already runs up to its concurrency ceiling of children through the same `ctx.subagents.start()` providers against the shared workspace. Only the foreground variant was serialized. + +## Decision + +`dsh-tool-subagent` declares `isConcurrencySafe: () => true` for every call form (foreground, one-shot background, continuable), so sibling delegations in one assistant step overlap under the loop's rolling pool up to `maxParallelToolCalls`, with results still committed in model order. + +The declaration satisfies the scheduler's safety contract structurally: a child works in its own session, a run never mutates the parent session (the start-time appends — `sandbox/mode`, `approval/policy`, `subagent/descriptor` — land only in the child's own log), and the tool returns its outputs to the loop for ordered commit. The one-shot background form's one parent-owned write is registering a Task through `tasks.start` — a synchronous, commutative insertion that satisfies the scheduler note's shared-state clause rather than the stronger no-mutation property. The provider seam requires concurrent starts and continuable preparations for distinct children to isolate operation-local state, cancellation, settlement, and cleanup. The bundled providers satisfy that contract: spawn and fork keep no mutable state between starts, fork reads only the parent's completed-turn prefix, out-of-process providers allocate state per run, and the continuation manager reserves a unique child identity and lock for each preparation. + +Coordinating sibling workspace effects is the model's responsibility, the stance the product already takes for background, continuable, and workflow children. Peer harnesses agree: Claude Code's Task tool is unconditionally concurrency-safe (cap 10), oh-my-pi's task tool defaults to its overlapping `shared` class, opencode's task tool runs unbounded under its SDK, and Codex sidesteps the question by making delegation an asynchronous spawn/wait mailbox. + +Capacity stays where the scheduler note put it: `maxParallelToolCalls` caps one step's unsettled tool calls — and therefore concurrently running foreground children — while background and continuable calls settle at start and free their pool slot, so children they leave running are not capped by it. LLM providers own their own capacity controls. + +## Testing + +Package tests pin the classifier for both call forms. A gate test drives the registry directly with two children that each block until both have started, proving the half the declaration depends on: the tool body and provider start path tolerate concurrent dispatch — hidden serialization in that stack would deadlock instead of passing silently. A continuable gate holds two provider preparations at the same await, cancels one caller before publication, and proves that the cancelled child leaves no Agent or durable Session while its sibling reaches inbox acceptance and persists independently. The scheduling half, classification actually producing overlap, is owned by the classifier pin and the snapshot below. + +The authored `subagent-parallel` snapshot pins the assembled-app transcript: one assistant message carries two subagent calls, the parent log records `tool/call, tool/call, tool/result, tool/result` (serial execution would interleave call/result pairs), and both children complete as separate sessions. Its twin delegations are deliberately identical: `dsh-llm-replay` binds child scripts by first-call order and the harvester orders children by `createdAt`, and neither is deterministic across concurrent children (the `XXX(concurrent-subagents)` marker), so only interchangeable twins replay race-free today. + +## Alternatives considered + +**Keep delegations exclusive.** The status quo protected nothing: background and workflow children already overlap freely with writes, so serializing the foreground variant only added latency and contradicted the model's explicit batching intent. + +**An input-sensitive classifier.** The call's arguments are a free-text description and prompt; nothing in them distinguishes a safe delegation from an unsafe one, so a conditional classifier would be theater. + +**A Codex-style asynchronous spawn/wait redesign.** Continuable children plus `send_message` already provide the asynchronous channel; rebuilding the foreground contract around a mailbox would discard a working synchronous result path to solve a scheduling problem one declaration fixes. + +**A per-instance `concurrencySafe` config knob.** No consumer needs a serial deployment: `maxParallelToolCalls: 1` already restores global serial execution, and peer-harness prior art defaults delegation to concurrency-safe. + +## Consequences + +Sibling children can race on shared workspace or external resources; the model owns that coordination, as it already does for every other overlapping child. Concurrent children also compete for LLM provider quota; `maxParallelToolCalls` caps only unsettled calls, not children a background or continuable call left running. + +Two one-shot background delegations in one message acquire their model-visible task ids (`subagent-`) in dispatch-race order. The ids are logged, so replay stays valid, but a snapshot scenario that distinguishes its background children would inherit the same determinism constraint as twin child sessions. + +Ordered commits may hold a fast child's result behind a slow earlier sibling — the trade the [scheduler note](2026-07-10-parallel-tool-call-execution.md) already accepted; live surfaces still show each child's own progress. + +A concurrent-children snapshot scenario with distinct prompts still needs replay-harness support (deterministic child-script binding and harvest ordering); until then such scenarios must use interchangeable twin delegations. diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md new file mode 100644 index 0000000000..2c2bd700e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 并行 subagent 委派 + +Status: implemented + +[English](2026-08-09-parallel-subagent-delegations.md) | 中文 + +## 问题 + +想要扇出的模型会把多个 `subagent` 调用合并进同一条 assistant 消息:这个批次本身就是并行意图。委派工具此前没有声明 `isConcurrencySafe` 分类器,按安全侧原则设计的调度器([并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.md))便把每个前台委派都当作独占屏障:GUI 里显示九张卡片,却只有一个子 agent(智能体)在运行,其余八个要在它的整个运行期间排在其后等待。 + +最初的保守立场(一元分类器无法证明同级委派的工作区效果互不相交)已经不再保护任何东西:`run_in_background: true` 和可继续委派本来就会与其后的每个调用重叠执行,包括写入;`dsh-workflow-workerthread` 也早已通过同样的 `ctx.subagents.start()` 提供方在共享工作区上并发运行子 agent,数量可达其并发上限。只有前台形态被串行化。 + +## 决策 + +`dsh-tool-subagent` 为每种调用形态(前台、一次性后台、可继续)都声明 `isConcurrencySafe: () => true`,因此同一 assistant 步骤中的同级委派会在循环的滚动池下重叠执行,上限为 `maxParallelToolCalls`,结果仍按模型顺序提交。 + +该声明在结构上满足调度器的安全约定:子 agent 在自己的会话中工作,运行绝不变更父会话(启动时追加的 `sandbox/mode`、`approval/policy`、`subagent/descriptor` 只落在子 agent 自己的日志里),工具把输出返回给循环,由循环按顺序提交。一次性后台形态对父级拥有状态的唯一写入是通过 `tasks.start` 注册一个 Task——这是一次同步、可交换的插入,满足的是调度器 Agent Note 中的共享状态条款,而非更强的「无变更」性质。提供方 seam 要求针对不同子 agent 的并发启动和可继续准备分别隔离操作局部状态、取消、结算和清理。内置提供方满足这项约定:spawn 和 fork 在各次启动之间不保留可变状态,fork 只读取父级已完成轮次的前缀,进程外提供方按每次运行分配状态,继续执行管理器则为每次准备预留唯一的子 agent 身份和锁。 + +协调同级工作区效果是模型的职责,产品对后台、可继续和工作流子 agent 已经采取同样的立场。同类 harness 的做法一致:Claude Code 的 Task 工具无条件并发安全(上限 10);oh-my-pi 的 task 工具默认归入其可重叠的 `shared` 类别;opencode 的 task 工具在其 SDK 下不设上限地运行;Codex 则把委派做成异步 spawn/wait 信箱,绕开了这个问题。 + +容量控制仍保持在调度器 Agent Note 所定的位置:`maxParallelToolCalls` 限制单个步骤中未结算的工具调用数量——因而也限制并发运行的前台子 agent 数量——而后台和可继续调用在启动时即结算并释放池位,它们留下运行的子 agent 不受该上限约束。LLM(大语言模型)提供方负责自身的容量控制。 + +## 测试 + +包测试固定了两种调用形态的分类器。一个门控测试直接驱动注册表,其两个子 agent 各自阻塞,直到两者都已启动,以此证明该声明所依赖的那一半:工具体和提供方启动路径能容忍并发分发——这条栈中任何隐藏的串行化都会造成死锁,而不是静默通过。一个可继续门控测试让两项提供方准备停在同一个 await 上,在发布前取消其中一个调用方,并证明已取消的子 agent 不会留下 agent 或持久会话,而其同级则到达 inbox 接受状态并独立持久化。另一半(分类真正产生重叠执行)由分类器 pin 测试和下述快照负责。 + +人工编写的 `subagent-parallel` 快照固定了组装后应用的 transcript(文本记录):一条 assistant 消息携带两个 subagent 调用,父级日志记录为 `tool/call, tool/call, tool/result, tool/result`(串行执行会让调用/结果成对交错出现),两个子 agent 各自作为独立会话完成。其中的孪生委派刻意做成完全相同:`dsh-llm-replay` 按首次调用顺序绑定子脚本,harvester 按 `createdAt` 对子 agent 排序,二者在并发子 agent 之间都不具确定性(即 `XXX(concurrent-subagents)` 标记),因此目前只有可互换的孪生委派才能无竞态地回放。 + +## 备选方案 + +**保持委派独占。** 现状没有保护任何东西:后台和工作流子 agent 本来就可以带着写入自由重叠,串行化前台形态只会增加延迟,还违背模型显式表达的批量意图。 + +**使用输入敏感的分类器。** 该调用的参数只有自由文本的描述和提示词;其中没有任何内容能区分安全委派与不安全委派,因此条件式分类器只会流于形式。 + +**按 Codex 风格重新设计为异步 spawn/wait。** 可继续子 agent 加上 `send_message` 已经提供了异步通道;围绕信箱重建前台约定,等于为了解决一条声明就能修复的调度问题,丢弃一条可用的同步结果路径。 + +**按实例提供 `concurrencySafe` 配置开关。** 没有消费方需要串行部署:`maxParallelToolCalls: 1` 已能恢复全局串行执行,同类 harness 的先例也默认委派并发安全。 + +## 影响 + +同级子 agent 可能在共享工作区或外部资源上发生竞态;这项协调由模型负责,正如模型对其他所有重叠子 agent 已经承担的那样。并发子 agent 还会争用 LLM 提供方配额;`maxParallelToolCalls` 只限制未结算的调用,不限制后台或可继续调用留下运行的子 agent。 + +同一条消息中的两个一次性后台委派按分发竞态顺序获得各自模型可见的 task id(`subagent-`)。这些 id 已被记录,因此回放仍然有效;但需要区分后台子 agent 的快照场景会继承与孪生子会话相同的确定性约束。 + +有序提交可能让快速子 agent 的结果排在更早的缓慢同级之后等待,这是[调度器 Agent Note](2026-07-10-parallel-tool-call-execution.md)已经接受的取舍;实时界面仍会展示每个子 agent 各自的进度。 + +使用不同提示词的并发子 agent 快照场景仍需要回放 harness 的支持(确定性的子脚本绑定与收集排序);在此之前,此类场景必须使用可互换的孪生委派。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 86ecf90b02..a5767904b9 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subagent.md -subagent.md: 42359215b2e21028e13734c2a4cd2488f86a07af -subagent.zh.md: 0d1d4c42fa774867679ff248bfc99b0024b04257 +subagent.md: cf728d9f15fdaaf8199954e91b564167e8efe439 +subagent.zh.md: 1a8e2e5837b8ac88f7d7b7ca767fb7aa21391a9f diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 42359215b2..cf728d9f15 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -390,7 +390,10 @@ Each provider is a named child-agent transport, and multiple providers may coexi /** * One registered transport for running child agents. Providers are trusted * same-process implementations; callers treat descriptors and returned values - * as borrowed immutable data. + * as borrowed immutable data. The service may call one provider concurrently + * for distinct children. Providers isolate operation-local mutable state; a + * shared capacity controller may delay an operation but must not couple its + * settlement or cleanup to a sibling. */ interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -411,7 +414,8 @@ interface SubagentProvider { * initial turn. Before fulfillment, the provider owns setup and cleans any * unpublished partial resources before rejecting. Ownership transfers on * fulfillment; subsequent turn or infrastructure failure settles through - * the returned run. + * the returned run. Distinct starts may overlap; cancellation, failure, + * result settlement, and disposal remain independent for each run. */ start(request: ResolvedSubagentStartRequest): Promise /** @@ -426,6 +430,8 @@ interface SubagentProvider { * continuation manager owns identity reservation, composition, Agent * creation, prompt delivery, cold resume, ownership, and disposal, so a * provider never sees the child's Agent, handle, turns, or teardown. + * Distinct preparations may overlap; each follows its own signal and returns + * data belonging only to `request.sessionId`. */ prepareContinuable?(request: ContinuableCreateRequest): Promise } diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 0d1d4c42fa..1a8e2e5837 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -392,7 +392,10 @@ interface SubagentRun { /** * One registered transport for running child agents. Providers are trusted * same-process implementations; callers treat descriptors and returned values - * as borrowed immutable data. + * as borrowed immutable data. The service may call one provider concurrently + * for distinct children. Providers isolate operation-local mutable state; a + * shared capacity controller may delay an operation but must not couple its + * settlement or cleanup to a sibling. */ interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -413,7 +416,8 @@ interface SubagentProvider { * initial turn. Before fulfillment, the provider owns setup and cleans any * unpublished partial resources before rejecting. Ownership transfers on * fulfillment; subsequent turn or infrastructure failure settles through - * the returned run. + * the returned run. Distinct starts may overlap; cancellation, failure, + * result settlement, and disposal remain independent for each run. */ start(request: ResolvedSubagentStartRequest): Promise /** @@ -428,6 +432,8 @@ interface SubagentProvider { * continuation manager owns identity reservation, composition, Agent * creation, prompt delivery, cold resume, ownership, and disposal, so a * provider never sees the child's Agent, handle, turns, or teardown. + * Distinct preparations may overlap; each follows its own signal and returns + * data belonging only to `request.sessionId`. */ prepareContinuable?(request: ContinuableCreateRequest): Promise } diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index a05bce3269..8afa1525bf 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -346,6 +346,12 @@ const SCENARIOS: Scenario[] = [ // parent's tool result must retain that assistant output and stop reason. { name: 'subagent-max-tokens-partial', hasModelTurn: true, recorded: false }, { name: 'subagent-multi', hasModelTurn: true, recorded: true }, + // Authored keyless replay: one assistant message carries two subagent calls + // and the parent log pins call/call/result/result instead of the serial + // interleaving. The twin delegations must stay identical: replay binds child + // scripts and harvest order nondeterministically across concurrent children + // (XXX(concurrent-subagents) in dsh-llm-replay). + { name: 'subagent-parallel', hasModelTurn: true, recorded: false }, { name: 'subagent-fork', hasModelTurn: true, recorded: true }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, // Authored continuable-subagent transcript: a background delegation returns diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/input.json b/examples/acp-agent/tests/snapshots/subagent-parallel/input.json new file mode 100644 index 0000000000..86e72676ee --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl new file mode 100644 index 0000000000..3555ca83de --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"bbbbbbbb-0000-4000-8000-000000000002","createdAt":1783352127000,"cwd":"{{cwd}}","parentSession":"aaaaaaaa-0000-4000-8000-000000000001","origin":"subagent","delegationDepth":1} +{"type":"approval/policy","seq":0,"time":1786373947132,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786373947134,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f5d60a48-78a3-4d75-91d7-478017516c5c"}]}} +{"type":"turn/start","seq":2,"time":1786373947134,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786373947134,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786373947165,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} +{"type":"step/start","seq":5,"time":1786373947168,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1786338530759,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f5d60a48-78a3-4d75-91d7-478017516c5c"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786373947168,"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`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"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`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"635fd4be-77c6-4891-90ba-2cafa163e166"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786373947168,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1786338530759,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1786338530759,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":13,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":1786338530769,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4d13c6bf-dc50-4e57-91b0-59561b58986e"}},"sourceEventSeqs":[11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1786338530769,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":1786338530769,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl new file mode 100644 index 0000000000..6bb570bdaf --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"cccccccc-0000-4000-8000-000000000003","createdAt":1783352127001,"cwd":"{{cwd}}","parentSession":"aaaaaaaa-0000-4000-8000-000000000001","origin":"subagent","delegationDepth":1} +{"type":"approval/policy","seq":0,"time":1786373947132,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786373947133,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"afacd216-5e1b-46e7-afc4-f0eba9da1814"}]}} +{"type":"turn/start","seq":2,"time":1786373947133,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786373947134,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786373947170,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} +{"type":"step/start","seq":5,"time":1786373947173,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1786338530749,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"afacd216-5e1b-46e7-afc4-f0eba9da1814"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786373947174,"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`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"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`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"dde02975-239c-422e-9b63-27d98f73346a"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786373947174,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1786338530749,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1786338530749,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":13,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":1786338530759,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"11e93a53-9112-4e80-9447-de974373ca94"}},"sourceEventSeqs":[11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1786338530760,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":1786338530760,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.jsonl new file mode 100644 index 0000000000..7e2d0e05bb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"aaaaaaaa-0000-4000-8000-000000000001","createdAt":1783352126000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786338530687,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"02062dd0-83d4-4b40-ab23-2fbcb0a8be96"}]}} +{"type":"turn/start","seq":1,"time":1786338530688,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786338530688,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786338530716,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786338530716,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"02062dd0-83d4-4b40-ab23-2fbcb0a8be96"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786338530716,"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":"2dd192ab-72ed-4c20-a487-40aa14bd5c07"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786338530716,"data":{"title":"Use the subagent tool TWICE","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786338530717,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786338530717,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":13,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1786338530723,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"},{"type":"tool-call","id":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6ff33634-55af-4c37-a491-dd5b8673923f"}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786338530723,"data":{"turn":1,"step":1,"callId":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/call","seq":16,"time":1786338530723,"data":{"turn":1,"step":1,"callId":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":17,"time":1786338530770,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parallel_alpha_1"},"content":[{"type":"tool-result","toolCallId":"call_parallel_alpha_1","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"9fa4fa59-7326-4bc8-8ea2-120b37d91ea3"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":18,"time":1786338530770,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parallel_alpha_2"},"content":[{"type":"tool-result","toolCallId":"call_parallel_alpha_2","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"895bb620-534e-4857-9743-68273126eccf"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786338530770,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1786338530778,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":23,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1786338530782,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"84586dd8-4985-4286-ab4b-fa9965803fb8"}},"sourceEventSeqs":[21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1786338530782,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1786338530782,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl new file mode 100644 index 0000000000..a460e019d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index c70bcdf58b..f3df2a0e62 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 28f649ef54bbf88feda24a9ce197c2c366f8349b -README.zh.md: 595c5e5e7fffc367f2e3fd8142b779dc22fb3b79 +README.md: b9f65befab71edc0685f1a6cca767c803afcb76c +README.zh.md: f58975815742ccbe790ca1cf096449831144a2f3 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 28f649ef54..b9f65befab 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -44,7 +44,7 @@ Every in-process child is composed by one call, `applyChildComposition(childCtx, `childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. -Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. +Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. The service may invoke one provider concurrently for distinct siblings: each start or preparation owns its mutable state and cancellation path, and one operation's failure, result, or cleanup must not settle or release another. A provider may queue its own capacity internally without changing that independence contract. ## The durable descriptor diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 595c5e5e7f..f589758157 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -44,7 +44,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 -可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。 +可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。服务可以针对不同的同级子 agent 并发调用同一提供方:每次启动或准备都拥有各自的可变状态和取消路径,一项操作的失败、结果或清理不得使另一项操作结算或释放。提供方可以在内部按自身容量排队,但不得改变这项独立性约定。 ## 持久化描述符 diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 63a890176e..dffea92fbd 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -277,7 +277,10 @@ export interface SubagentRun { /** * One registered transport for running child agents. Providers are trusted * same-process implementations; callers treat descriptors and returned values - * as borrowed immutable data. + * as borrowed immutable data. The service may call one provider concurrently + * for distinct children. Providers isolate operation-local mutable state; a + * shared capacity controller may delay an operation but must not couple its + * settlement or cleanup to a sibling. */ export interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -298,7 +301,8 @@ export interface SubagentProvider { * initial turn. Before fulfillment, the provider owns setup and cleans any * unpublished partial resources before rejecting. Ownership transfers on * fulfillment; subsequent turn or infrastructure failure settles through - * the returned run. + * the returned run. Distinct starts may overlap; cancellation, failure, + * result settlement, and disposal remain independent for each run. */ start(request: ResolvedSubagentStartRequest): Promise /** @@ -313,6 +317,8 @@ export interface SubagentProvider { * continuation manager owns identity reservation, composition, Agent * creation, prompt delivery, cold resume, ownership, and disposal, so a * provider never sees the child's Agent, handle, turns, or teardown. + * Distinct preparations may overlap; each follows its own signal and returns + * data belonging only to `request.sessionId`. */ prepareContinuable?(request: ContinuableCreateRequest): Promise } diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 8c41c6413a..7e038e3fa7 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/subagent/tool-subagent/README.md -README.md: ac3ec0563cce9128608ca31860b034a103dc1a3a -README.zh.md: d64831d7cf64800ad3307ce6cb7f294500a0a6f0 +README.md: 578ea4786e1d996251360c4aed92f2e882553681 +README.zh.md: baf91664530a637df96f1e7a51e6f0adabc0a8ae diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index ac3ec0563c..578ea4786e 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -29,7 +29,7 @@ With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` r ## Concurrency -Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and a unary classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). +Foreground and background calls are concurrency-safe: sibling delegations in one assistant message overlap under the loop's rolling pool (`maxParallelToolCalls`), and results still commit in model order. Children work in their own sessions and a run never mutates the parent session; the one-shot background form's one parent-owned write — registering a Task — is a synchronous, commutative insertion that tolerates concurrent dispatch, so overlapping background calls acquire their task ids in dispatch-race order. Coordinating sibling workspace effects belongs to the model, exactly as it already does for background and continuable children. See the [parallel subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) and the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). ## Model Experience diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index d64831d7cf..baf9166453 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -29,7 +29,7 @@ ## 并发 -前台调用和后台调用均互斥。子 agent 可能共享父级工作区或外部资源,一元分类器无法证明同级委派的效果彼此不相交。见 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 +前台调用和后台调用均并发安全:同一条 assistant 消息中的同级委派会在循环的滚动池(`maxParallelToolCalls`)下重叠执行,结果仍按模型顺序提交。子 agent 在各自的会话中工作,一次运行绝不变更父会话;一次性后台形态对父级拥有状态的唯一写入是注册一个 Task——这是一次同步、可交换、能容忍并发分发的插入,因此重叠的后台调用按分发竞态顺序获得各自的 task id。协调同级工作区效果由模型负责,正如模型已经对后台和可继续子 agent 所承担的那样。见 [并行 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) 和 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 ## 模型体验 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 13781bde91..88ed0cdfc1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -330,6 +330,9 @@ export function apply(ctx: Context, config: Config): void { : outputValueText(value.output), }], }, + // Children never mutate the parent session; the one parent-owned write + // (tasks.start) is a synchronous commutative insertion. + isConcurrencySafe: () => true, async execute(args, exec) { const parent = exec.agent if (!parent) { diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index df35e6dbdd..0be724a8bb 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -33,6 +33,8 @@ export interface Config { inheritsParentContext?: boolean /** Structured value returned when the request asks for one. */ structured?: unknown + /** Observes each start; the child's result additionally waits for the returned promise. */ + onStart?: (request: SubagentStartRequest) => Promise | void } /** Scripted provider whose result aborts if its signal or disposer wins first. */ @@ -68,9 +70,10 @@ class ScriptedSubagentProvider implements SubagentProvider { ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, stopReason: state.cancelled ? 'aborted' : stopReason, }) - const result = new Promise((resolve) => { + const gate = Promise.resolve(this.config.onStart?.(request)) + const result = gate.then(() => new Promise((resolve) => { setTimeout(() => { resolve(resultFor()) }, 0) - }).finally(() => { + })).finally(() => { request.signal.removeEventListener('abort', onAbort) }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 20cb11db2b..6cf8dee746 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -128,20 +128,42 @@ describe('dsh-tool-subagent', () => { expect(foreground.isError).toBe(false) }) - it('keeps foreground and background calls exclusive', async () => { + it('classifies foreground and background calls concurrency-safe (sibling delegations overlap)', async () => { const ctx = await setup({ provider: 'mock' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('subagent-foreground'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK' }, - })).toEqual({ kind: 'exclusive' }) + })).toEqual({ kind: 'parallel' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('subagent-background'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true }, - })).toEqual({ kind: 'exclusive' }) + })).toEqual({ kind: 'parallel' }) + }) + + it('overlaps sibling foreground delegations dispatched concurrently', async () => { + // Two children each block until both have started: hidden serialization + // in the tool body, registry pipeline, or provider start path would + // deadlock here instead of passing silently. + const started: string[] = [] + let releaseBoth!: () => void + const bothStarted = new Promise((resolve) => { releaseBoth = resolve }) + const ctx = await setup({ provider: 'mock', enableRunInBackground: false }, { + onStart: (request: SubagentStartRequest) => { + started.push(request.label ?? '(unlabeled)') + if (started.length === 2) releaseBoth() + return bothStarted + }, + }) + const results = await Promise.all([ + callSubagent(ctx, { description: 'first', prompt: 'p1' }), + callSubagent(ctx, { description: 'second', prompt: 'p2' }), + ]) + expect(started.sort()).toEqual(['first', 'second']) + for (const result of results) expect(result.isError).toBe(false) }) it.each([ @@ -957,6 +979,16 @@ describe('dsh-tool-subagent continuable background mode', () => { return { ctx, parent } } + it('classifies continuable background calls concurrency-safe', async () => { + const { ctx } = await continuableSetup() + expect(ctx.tools.executionMode({ + signal: testToolSignal, + callId: CallId('subagent-continuable'), + name: 'subagent', + arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true }, + })).toEqual({ kind: 'parallel' }) + }) + it('starts a continuable child and returns only its durable id, creating no Task', async () => { const { ctx, parent } = await continuableSetup() const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! @@ -986,6 +1018,69 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) }) + it('isolates a cancelled continuable preparation from a concurrent sibling', async () => { + const { ctx, parent } = await continuableSetup() + const bothPreparing = Promise.withResolvers() + const releasePreparations = Promise.withResolvers() + const cancelled = new AbortController() + let preparationCount = 0 + let cancelledChildId: ReturnType | undefined + let survivingChildId: ReturnType | undefined + ctx.subagents.registerProvider({ + name: 'gated', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + start: async () => { throw new Error('continuable policy must not start a one-shot child') }, + prepareContinuable: async (request) => { + preparationCount += 1 + if (request.signal === cancelled.signal) cancelledChildId = request.sessionId + else survivingChildId = request.sessionId + if (preparationCount === 2) bothPreparing.resolve(undefined) + await releasePreparations.promise + return {} + }, + }) + tool.apply(ctx, { + provider: 'gated', + toolName: 'subagent_gated', + backgroundMode: 'continuable', + maxDepth: 3, + }) + + const execute = (callId: string, description: string, signal: AbortSignal) => ctx.tools.execute({ + signal, + callId: CallId(callId), + name: 'subagent_gated', + arguments: { description, prompt: 'work', run_in_background: true }, + agent: parent, + }) + const cancelledResult = execute('continuable-cancelled', 'cancelled sibling', cancelled.signal) + const survivingResult = execute('continuable-surviving', 'surviving sibling', testToolSignal) + await bothPreparing.promise + cancelled.abort() + releasePreparations.resolve(undefined) + + const [failed, succeeded] = await Promise.all([cancelledResult, survivingResult]) + expect(preparationCount).toBe(2) + expect(failed.isError).toBe(true) + expect(succeeded.isError).toBe(false) + expect(cancelledChildId).toBeDefined() + expect(survivingChildId).toBeDefined() + expect(ctx.agents.get(cancelledChildId!)).toBeUndefined() + await expect(ctx.sessionPersistence.load(cancelledChildId!)).rejects.toThrow(/not found/) + + expect(succeeded.isError ? undefined : succeeded.value).toEqual({ + kind: 'continuable', + subagentId: survivingChildId, + }) + await vi.waitFor(() => { + expect(ctx.agents.get(survivingChildId!)).toBeUndefined() + }, { timeout: 5_000 }) + const loaded = await ctx.sessionPersistence.load(survivingChildId!) + expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) + expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) + }) + }) describe('background preflight failure (no orphaned child, by construction)', () => { From 682451d00134902d5c12acf7a0e988e723a74b17 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:17:08 -0700 Subject: [PATCH 77/77] fix(ci): refresh merged doc pairings --- docs/config-catalog.i18n.yaml | 4 ++-- docs/module-graph.i18n.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 3693fdf977..a20a76a7ef 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: 6e7d6d6214af81cc89c7578454b0004ca0c52e1f -config-catalog.zh.md: e3425e0ab74d2ba8a879a1cfc1acf0d79518743b +config-catalog.md: 6188fec1cfe65369e59c10b851629122c393af1e +config-catalog.zh.md: 1aefcaff17d272d4767a22a5ba72a7ab6dc0b914 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d54af9af70..3f5820ec3e 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: 7eac17bea2849bcc3730b4165eb55de4457ec66d -module-graph.zh.md: e6a8758f50481e849ccf4c33794b5468d81601ee +module-graph.md: 859a449f63bd36177a9c666ac9d894cb495b224f +module-graph.zh.md: cedf087810cf309200368b43c6d53757426c58cd