From ad3632f12219e9ac2f47722f3651a8df8ee7fa5c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 27 Jul 2026 15:25:33 +0800 Subject: [PATCH 001/137] feat(web): add file and session references --- ...eb-command-surfaces-and-assembly.i18n.yaml | 6 +- ...07-25-web-command-surfaces-and-assembly.md | 12 +- ...25-web-command-surfaces-and-assembly.zh.md | 12 +- ...6-07-21-cross-session-references.i18n.yaml | 6 +- .../2026-07-21-cross-session-references.md | 8 +- .../2026-07-21-cross-session-references.zh.md | 8 +- ...-tui-file-reference-autocomplete.i18n.yaml | 6 +- ...6-07-23-tui-file-reference-autocomplete.md | 8 +- ...7-23-tui-file-reference-autocomplete.zh.md | 8 +- ...-web-file-and-session-references.i18n.yaml | 6 + ...6-07-27-web-file-and-session-references.md | 49 +++ ...7-27-web-file-and-session-references.zh.md | 49 +++ apps/cli/cordis.yml | 17 +- apps/cli/package.json | 6 +- apps/web/tests/slash-flow.snapshot.ts | 78 ++++- docs/capability-seams.md | 10 +- docs/config-catalog.md | 23 +- docs/cordis-catalog/events.md | 16 +- docs/cordis-catalog/services.md | 21 +- docs/event-producer-consumer.md | 20 +- docs/module-graph.md | 76 ++-- packages/client/connection/src/client/api.ts | 1 + .../client/connection/src/client/fixture.ts | 32 ++ .../client/connection/src/client/index.ts | 1 + packages/client/connection/tests/fake-api.ts | 5 + .../src/client/sessions/conversation.ts | 5 + .../src/client/sessions/fold-adapter.ts | 11 +- .../runtime/src/client/sessions/session.ts | 9 +- packages/client/runtime/tests/fake-api.ts | 5 + .../client/runtime/tests/fold-adapter.spec.ts | 33 ++ packages/client/runtime/tests/session.spec.ts | 8 +- packages/client/tsdown.client.ts | 2 +- .../client/ui-conversation/README.i18n.yaml | 6 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/chat/MessageItem.module.css | 20 +- .../src/client/chat/MessageItem.tsx | 59 +++- .../src/client/input/contract.ts | 9 +- .../src/client/input/facade.ts | 54 ++- .../ui-conversation/src/client/input/hub.ts | 44 +-- .../src/client/input/machine.ts | 35 +- .../tests/apply-inject.spec.tsx | 23 +- .../tests/chat-branch-tails.spec.tsx | 46 +++ .../ui-conversation/tests/input-bar.spec.tsx | 8 +- .../tests/input-machine.spec.ts | 20 +- .../tests/input-matrix.spec.tsx | 13 +- .../tests/input-reference-submit.spec.ts | 116 +++++++ .../tests/input-scenarios.spec.tsx | 10 +- .../ui-conversation/tests/skeleton.spec.tsx | 4 +- .../README.i18n.yaml | 6 +- packages/client/ui-reference/README.md | 25 ++ packages/client/ui-reference/README.zh.md | 25 ++ .../package.json | 11 +- .../client/ui-reference/src/client/index.ts | 115 +++++++ .../src/index.ts | 2 +- .../src/invariant.ts | 8 +- .../ui-reference/tests/browser-plugin.spec.ts | 325 ++++++++++++++++++ .../tsconfig.json | 5 +- packages/client/ui-reference/tsdown.config.ts | 3 + packages/client/ui-slash/README.i18n.yaml | 6 +- packages/client/ui-slash/README.md | 8 +- packages/client/ui-slash/README.zh.md | 8 +- packages/client/ui-slash/package.json | 2 + .../ui-slash/src/client/MenuView.module.css | 14 + .../client/ui-slash/src/client/MenuView.tsx | 44 +-- .../client/ui-slash/src/client/controller.ts | 14 +- packages/client/ui-slash/src/core/contract.ts | 7 +- packages/client/ui-slash/src/core/detect.ts | 25 +- packages/client/ui-slash/src/types.ts | 12 +- .../client/ui-slash/tests/core-detect.spec.ts | 11 + .../client/ui-slash/tests/core-menu.spec.ts | 1 + .../client/ui-slash/tests/menu-view.spec.tsx | 26 ++ packages/client/ui-slash/tsconfig.json | 3 + packages/client/ui-subagent/README.md | 31 -- packages/client/ui-subagent/README.zh.md | 31 -- .../client/ui-subagent/src/client/index.ts | 58 ---- .../client/ui-subagent/src/css-modules.d.ts | 6 - .../ui-subagent/tests/browser-plugin.spec.ts | 145 -------- packages/client/ui-subagent/tsdown.config.ts | 3 - .../file-reference-local/README.i18n.yaml | 6 + .../context/file-reference-local/README.md | 45 +++ .../context/file-reference-local/README.zh.md | 45 +++ .../context/file-reference-local/package.json | 53 +++ .../context/file-reference-local/src/index.ts | 140 ++++++++ .../file-reference-local/src/invariant.ts | 30 ++ .../file-reference-local/src/search.ts} | 86 +---- .../tests/invariant.spec.ts | 12 + .../tests/search.spec.ts} | 6 +- .../tests/service.spec.ts | 161 +++++++++ .../file-reference-local/tsconfig.json | 33 ++ .../context/file-reference/README.i18n.yaml | 6 + packages/context/file-reference/README.md | 22 ++ packages/context/file-reference/README.zh.md | 22 ++ packages/context/file-reference/package.json | 44 +++ .../context/file-reference/src/grammar.ts | 55 +++ packages/context/file-reference/src/index.ts | 51 +++ .../context/file-reference/src/invariant.ts | 30 ++ .../file-reference/tests/invariant.spec.ts | 12 + packages/context/file-reference/tsconfig.json | 21 ++ .../cordis/tool-cordis/src/api-catalog.ts | 14 + packages/core/session/package.json | 4 + packages/core/session/src/display.ts | 17 + packages/core/session/src/index.ts | 14 +- packages/host/apiproxy/README.i18n.yaml | 6 +- packages/host/apiproxy/README.md | 8 +- packages/host/apiproxy/README.zh.md | 8 +- packages/host/apiproxy/package.json | 2 + packages/host/apiproxy/src/api-proxy.ts | 151 +++++++- packages/host/apiproxy/src/api/index.ts | 3 + .../apiproxy/src/api/references.schema.ts | 42 +++ packages/host/apiproxy/src/api/references.ts | 39 +++ packages/host/apiproxy/src/api/rpc-map.ts | 3 + packages/host/apiproxy/src/api/rpc.schema.ts | 3 + packages/host/apiproxy/src/api/rpc.ts | 3 + packages/host/apiproxy/src/api/sessions.ts | 11 +- packages/host/apiproxy/src/fetch/client.ts | 15 + packages/host/apiproxy/src/fetch/handler.ts | 10 +- packages/host/apiproxy/src/index.ts | 2 + .../tests/api-proxy-references.spec.ts | 214 ++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 39 +++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 8 + packages/host/apiproxy/tsconfig.json | 6 + packages/ui/tui/package.json | 2 + packages/ui/tui/src/index.ts | 9 +- packages/ui/tui/tests/tui.spec.ts | 2 +- packages/ui/tui/tsconfig.json | 3 + pnpm-lock.yaml | 106 ++++-- scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 13 +- .../verify-package-readme-model-experience.ts | 4 +- tsconfig.base.json | 4 +- tsconfig.client.json | 2 +- tsconfig.host.json | 2 + vitest.config.ts | 2 +- 134 files changed, 2979 insertions(+), 681 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md create mode 100644 packages/client/ui-conversation/tests/input-reference-submit.spec.ts rename packages/client/{ui-subagent => ui-reference}/README.i18n.yaml (55%) create mode 100644 packages/client/ui-reference/README.md create mode 100644 packages/client/ui-reference/README.zh.md rename packages/client/{ui-subagent => ui-reference}/package.json (78%) create mode 100644 packages/client/ui-reference/src/client/index.ts rename packages/client/{ui-subagent => ui-reference}/src/index.ts (79%) rename packages/client/{ui-subagent => ui-reference}/src/invariant.ts (83%) create mode 100644 packages/client/ui-reference/tests/browser-plugin.spec.ts rename packages/client/{ui-subagent => ui-reference}/tsconfig.json (81%) create mode 100644 packages/client/ui-reference/tsdown.config.ts delete mode 100644 packages/client/ui-subagent/README.md delete mode 100644 packages/client/ui-subagent/README.zh.md delete mode 100644 packages/client/ui-subagent/src/client/index.ts delete mode 100644 packages/client/ui-subagent/src/css-modules.d.ts delete mode 100644 packages/client/ui-subagent/tests/browser-plugin.spec.ts delete mode 100644 packages/client/ui-subagent/tsdown.config.ts create mode 100644 packages/context/file-reference-local/README.i18n.yaml create mode 100644 packages/context/file-reference-local/README.md create mode 100644 packages/context/file-reference-local/README.zh.md create mode 100644 packages/context/file-reference-local/package.json create mode 100644 packages/context/file-reference-local/src/index.ts create mode 100644 packages/context/file-reference-local/src/invariant.ts rename packages/{ui/tui/src/file-autocomplete.ts => context/file-reference-local/src/search.ts} (78%) create mode 100644 packages/context/file-reference-local/tests/invariant.spec.ts rename packages/{ui/tui/tests/file-autocomplete.spec.ts => context/file-reference-local/tests/search.spec.ts} (97%) create mode 100644 packages/context/file-reference-local/tests/service.spec.ts create mode 100644 packages/context/file-reference-local/tsconfig.json create mode 100644 packages/context/file-reference/README.i18n.yaml create mode 100644 packages/context/file-reference/README.md create mode 100644 packages/context/file-reference/README.zh.md create mode 100644 packages/context/file-reference/package.json create mode 100644 packages/context/file-reference/src/grammar.ts create mode 100644 packages/context/file-reference/src/index.ts create mode 100644 packages/context/file-reference/src/invariant.ts create mode 100644 packages/context/file-reference/tests/invariant.spec.ts create mode 100644 packages/context/file-reference/tsconfig.json create mode 100644 packages/core/session/src/display.ts create mode 100644 packages/host/apiproxy/src/api/references.schema.ts create mode 100644 packages/host/apiproxy/src/api/references.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-references.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index d636aab9ff..01e9471093 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.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 -2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b -2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +2026-07-25-web-command-surfaces-and-assembly.md: 18ee22b8b359e7f2af15a69d2917774e8a9b609d +2026-07-25-web-command-surfaces-and-assembly.zh.md: e08c233662e16c06b3a0d352e2db9dc5e119e948 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md index 5188e8c17b..18ee22b8b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md @@ -1,10 +1,10 @@ -# Agent Note: Web command business surfaces and assembly (ui-command / ui-skill / ui-subagent) +# Agent Note: Web command business surfaces and assembly (ui-command / ui-skill / ui-reference) Status: implemented English | [中文](2026-07-25-web-command-surfaces-and-assembly.zh.md) -> Scope: the command directory cache and three-kind dispatch (ui-command), the popup selection flow, the two skill / subagent reference sources, and fixture command routing plus assembly acceptance (the slash-flow snapshot). The carrying wire lives in the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md); triggers, the menu, and the input machine live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md). +> Scope: the command directory cache and three-kind dispatch (ui-command), the popup selection flow, the skill and unified file/session reference sources, and fixture routing plus assembly acceptance (the slash-flow snapshot). The carrying wire lives in the [session scope note](2026-07-25-web-client-session-scope-and-provide-channel.md); triggers, the menu, and the input machine live in the [input machine note](2026-07-25-web-input-machine-and-slash-pipeline.md). Structured reference semantics are owned by [Web file and session references](../feature/2026-07-27-web-file-and-session-references.md). ## Problem @@ -29,7 +29,7 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx ### Reference sources (seeing only projections plus their own apply closures, on the root ctx) - **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). -- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream). +- **ui-reference**: one `@` source starts Host-backed file and session discovery together, renders files first, keeps quoted tokens file-only, continues directory picks, and represents sessions as atomic chips backed by canonical Host mentions. Host-side snapshot preparation and failure-preserving ordinary submission are specified by the owning [reference note](../feature/2026-07-27-web-file-and-session-references.md). ### Fixture command routing and assembly @@ -38,7 +38,7 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx ### Assembly-level acceptance: the slash-flow snapshot -`apps/web/tests/slash-flow.snapshot.ts` pins the user-visible main chain (assembled keyless; package mocks are no substitute for the assembled transcript): the composer disabled with no session → creating a Workspace and entering an already-materialized blank session → picking the `/echo` leadingInput from the `/` menu → the command executes but the blank bit does not flip and the list still shows `New Session` → the first ordinary prompt's successful acceptance converts that same row; the same session-bound textarea holds across blank → active. `workspace-flow.snapshot.ts` separately pins blank-row creation/reuse, first-prompt rejection backfill, and — on a Workspace switch before the first prompt — the draft moving across input machines with the old blank row hidden. +`apps/web/tests/slash-flow.snapshot.ts` pins the user-visible main chain (assembled keyless; package mocks are no substitute for the assembled transcript): the composer disabled with no session → creating a Workspace and entering an already-materialized blank session → completing a directory and file through `@` → picking the `/echo` leadingInput from the `/` menu → the command executes but the blank bit does not flip and the list still shows `New Session` → the first ordinary prompt's successful acceptance converts that same row; the same session-bound textarea holds across blank → active. A fixture branch selects an atomic `@session` chip. `workspace-flow.snapshot.ts` separately pins blank-row creation/reuse, first-prompt rejection backfill, and — on a Workspace switch before the first prompt — the draft moving across input machines with the old blank row hidden. ## Alternatives considered @@ -47,11 +47,11 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx | Inline prompt dispatch (command text riding the message into the host for parsing) | Conflates the command and message planes; command execution being independent of the message queue is existing host semantics | | A bridge materializing skills as commands | Skills have their own directory; N registrations would be a detour; the tag form naturally avoids the command plane | | A `skill.invoke` RPC | The host has no such operation; skill references are plain text riding prompts | -| A new ContentBlock reference type | Full-chain cost (adapters/UI/compaction); text-as-truth plus structured occurrence records suffices | +| A new ContentBlock reference type | Full-chain cost (adapters/UI/compaction); canonical mention text plus atomic composer state and Host preparation preserve identity without it | | Client packages self-reporting command directories | The host is the single source of truth; the client only reads descriptors, with `commands-changed` pushing invalidation | | The `requires: 'none' \| 'agent'` discriminant axis (an agentless directory + dual-addressed queries) | With sessions always agent-backed, the amphibious command has no owner; the whole axis reverts to master's shape, to be reopened on real demand | | Dedicated commandresult / commandpanel slots | Results go through notices; the popup shell is a skeleton-internal overlay; rich result cards sit in the ledger | -| An agent-type directory as the `@` source | No type registry exists; the live-session snapshot already covers it | +| A browser-side agent directory as the `@` source | Session-reference candidates are a Host capability with stable ids and persisted source surfaces; a browser-only running-child roster cannot provide them | | A PickAction/EnterCommand class family (class-inheritance pick products) | Cross-package runtime values break client bundle purity; pure data interfaces plus closure methods are equivalent | ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md index 0134cc10cf..e08c233662 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md @@ -1,10 +1,10 @@ -# Agent Note: Web 命令业务面与装配(ui-command / ui-skill / ui-subagent) +# Agent Note: Web 命令业务面与装配(ui-command / ui-skill / ui-reference) Status: implemented [English](2026-07-25-web-command-surfaces-and-assembly.md) | 中文 -> 范围:命令目录缓存与三型判定(ui-command)、popup 选择流、skill / subagent 两个引用源、fixture 命令路由与装配验收(slash-flow 快照)。承载 wire 见[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md);触发/菜单/输入机器见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md)。 +> 范围:命令目录缓存与三型判定(ui-command)、popup 选择流、skill(技能)与统一的文件/会话引用源,以及 fixture(测试前置数据)路由与装配验收(slash-flow 快照)。承载 wire 见[会话作用域 note](2026-07-25-web-client-session-scope-and-provide-channel.md);触发/菜单/输入机器见[输入状态机 note](2026-07-25-web-input-machine-and-slash-pipeline.md)。结构化引用语义由 [Web 文件与会话引用](../feature/2026-07-27-web-file-and-session-references.md)说明。 ## 问题 @@ -29,7 +29,7 @@ Status: implemented ### 引用源(只见投影 + 自家 apply 闭包的 root ctx) - **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。 -- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。 +- **ui-reference**:同一个 `@` source 会同时启动宿主支持的文件与会话发现,先渲染文件;带引号的 token 只显示文件;选择目录后继续补全;会话则表示为由宿主规范提及标记支撑的原子 chip。宿主侧快照准备和失败时保留内容的普通提交由对应的[引用 note](../feature/2026-07-27-web-file-and-session-references.md)定义。 ### fixture 命令路由与装配 @@ -38,7 +38,7 @@ Status: implemented ### 装配级验收:slash-flow 快照 -`apps/web/tests/slash-flow.snapshot.ts` 钉住用户可见主链(assembled keyless,包 mock 不替代装配转录):无 session 时 composer 禁用 → 创建 Workspace 并进入已实体化的 blank session → `/` 菜单选 `/echo` leadingInput → 命令执行但 blank 位不翻转、列表仍显示 `New Session` → 首条普通 prompt 成功受理后同一行转正;同一 session-bound textarea 跨 blank → active 保持。`workspace-flow.snapshot.ts` 另钉住 blank 行创建/复用、首讯拒绝回填,以及首讯前切换 Workspace 时 draft 跨 input machine 搬运且旧 blank 行隐藏。 +`apps/web/tests/slash-flow.snapshot.ts` 钉住用户可见主链(assembled keyless,包 mock 不替代装配后的 transcript(文本记录)):无 session 时 composer 禁用 → 创建 Workspace 并进入已实体化的 blank session → 通过 `@` 补全一个目录和文件 → `/` 菜单选 `/echo` leadingInput → 命令执行但 blank 位不翻转、列表仍显示 `New Session` → 首条普通 prompt 成功受理后同一行转正;同一 session-bound textarea 跨 blank → active 保持。fixture 分支会选择一个原子的 `@session` chip。`workspace-flow.snapshot.ts` 另钉住 blank 行创建/复用、首讯拒绝回填,以及首讯前切换 Workspace 时 draft 跨 input machine 搬运且旧 blank 行隐藏。 ## Alternatives considered @@ -47,11 +47,11 @@ Status: implemented | prompt 内联派发(命令文本随消息进 host 解析) | 混淆命令/消息平面;命令执行独立于消息队列是既有 host 语义 | | skill 物化为 command 的桥 | skill 自有目录;N 笔注册是绕路;标签形式天然避开命令平面 | | `skill.invoke` RPC | host 无此操作;skill 引用是随 prompt 的普通文本 | -| 新 ContentBlock 引用类型 | 全链路成本(adapter/UI/compaction);文本即真身 + 结构化 occurrence 记录已足够 | +| 新 ContentBlock 引用类型 | 全链路成本(适配器/UI/压缩);规范提及文本、原子 composer 状态与宿主准备无需该类型也能保留身份 | | client 各包自报命令目录 | host 是唯一真源;client 只读 descriptor,`commands-changed` 推失效 | | `requires: 'none' \| 'agent'` 判别轴(agentless 目录 + 双址查询) | 会话恒 agent-backed 后两栖命令无 owner;整轴回退 master 形状,待真需求重开 | | 专用 commandresult / commandpanel 坑位 | 结果走 notice;popup 壳是骨架内浮层;富结果卡入台账 | -| agent-type 目录做 `@` 源 | 无类型注册表;live-session 快照已覆盖 | +| 浏览器侧 agent 目录做 `@` 源 | 会话引用候选是宿主功能,具有稳定 id 和持久化的源表层;仅存在于浏览器中的运行中子会话 roster 无法提供这些信息 | | PickAction/EnterCommand 类族(类继承 pick 产物) | 跨包运行时值破坏 client bundle 纯度;纯数据接口 + 闭包方法等价 | ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index b1f73ed15a..64fe637064 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.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 -2026-07-21-cross-session-references.md: fc084b36e7920a72efff0f363278d24eaebc4c69 -2026-07-21-cross-session-references.zh.md: fe4a876b5265fa7ad298adf3b829bcec70e878e8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-cross-session-references.md +2026-07-21-cross-session-references.md: 0aca4606d27eab34102fef5bcd82a9565dc6599e +2026-07-21-cross-session-references.zh.md: 3f1cf4d863898bae806fab698b095fd7fd82e1f8 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index fc084b36e7..0aca4606d2 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -32,7 +32,9 @@ This preserves host driving semantics: TUI decides `send()` versus `steer()` fro ## Host adapters -TUI combines session candidates with the existing `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal. +TUI combines session candidates with the shared `@` file provider. Each candidate displays the latest folded session title and falls back to the session id; lookup follows the editor's cancellation signal, and session id, cwd, and mention labels escape external terminal controls while the canonical URI retains the original id. TUI prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, renders the prompt envelope's display content as the user message, and renders its session-reference metadata as a compact source list instead of exposing the complete JSON in the terminal. + +Web exposes the same candidate and preparation semantics through `reference.sessions` and `session.prompt`, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. The composer retains text and chips until preparation and enqueue succeed, then replay projects the logged display content and a compact session-source summary. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. @@ -53,8 +55,8 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, title-aware candidate ranking, terminal-control escaping, projection exclusions, non-recursive prompt-envelope projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer placement, title isolation, missing capability, Web wire preparation, failure-preserving Web submission, and compact TUI replay. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains one user message ordered as snapshot, request delimiter, and current prompt, without either shadowed string. A keyless Web snapshot pins the assembled reference selection path. ## Consequences -The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard TUI demo bundle mounts it explicitly and exposes its count and per-source byte limits in its config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant. +The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. The standard CLI composition mounts it explicitly for both TUI and Web and exposes its count and per-source byte limits in config; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index fe4a876b52..3f1cf4d863 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -32,7 +32,9 @@ TUI 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 宿主适配器 -TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 +TUI 把会话候选与共享的 `@` 文件提供方组合在一起。每个候选项显示最新折叠后的会话标题,没有标题时回退到 session id。候选查询遵循编辑器的取消信号;session id、cwd 和提及标签中的外部终端控制字符会被转义,但规范 URI 仍保留原始 id。TUI 只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;它把提示词封套的显示内容渲染为用户消息,并把其中的会话引用元数据渲染为精简的来源列表,不在终端中暴露完整 JSON。 + +Web 通过 `reference.sessions` 和 `session.prompt` 暴露相同的候选与准备语义,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。选择会话会创建由宿主生成的规范提及标记支撑的原子 chip。输入框会保留文本与 chip,直到准备和入队都成功;随后回放会投影日志中记录的显示内容和精简的会话来源摘要。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 @@ -53,8 +55,8 @@ TUI 把会话候选与现有 `@` 文件提供方组合在一起。每个候选 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失和精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、会考虑标题的候选排序、终端控制字符转义、投影排除规则、提示词封套的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 放置方式、标题隔离、功能缺失、Web 协议准备、失败时保留内容的 Web 提交,以及精简的 TUI 回放。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求只包含一条用户消息,其中依次为快照、请求分隔符和当前提示词,并且不包含任一被遮蔽的字符串。无密钥 Web 快照固定装配后的引用选择路径。 ## 后果 -新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUI 演示组合包会显式挂载它,并在自身配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 +新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 CLI(命令行界面)组合会为 TUI 和 Web 显式挂载它,并在配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml index 05b15028e6..c042b7168b 100644 --- a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.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 -2026-07-23-tui-file-reference-autocomplete.md: 1a136009213c845af28f4ac47a8b31d426ac8cf5 -2026-07-23-tui-file-reference-autocomplete.zh.md: 410f0d49dbd20a2dcf704892a192406020aaa86e +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md +2026-07-23-tui-file-reference-autocomplete.md: 93fd09efc826ff29531e90138939df8b3254a99a +2026-07-23-tui-file-reference-autocomplete.zh.md: 60dbfb577b11244f7dbece023333fff247cb1924 diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md index 1a13600921..93fd09efc8 100644 --- a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md @@ -10,9 +10,9 @@ The TUI offered structured `@session` references but no dependable way to discov ## Decision -The TUI owns a bounded, cancellable host-workspace path index rooted at the active session's working directory. Typing `@` at a token boundary fuzzy-matches files and directories; queries containing `/` list the named directory directly, accepting a directory continues completion, and paths containing whitespace use the `@"path with spaces"` form. Configuration controls result count, index size, and excluded directory basenames. The default exclusions are `.git` and `node_modules`; traversal does not follow directory symlinks or interpret ignore files. +The shared `@deepseek-ai/dsh-file-reference-local` provider owns a bounded, cancellable host-workspace path index rooted at each active session's working directory. TUI consumes its search and grammar implementation directly, while Web reaches the same capability through the Host API as recorded in [Web file and session references](2026-07-27-web-file-and-session-references.md). Typing `@` at a token boundary fuzzy-matches files and directories; queries containing `/` list the named directory directly, accepting a directory continues completion, and paths containing whitespace use the `@"path with spaces"` form. Configuration controls result count, index size, and excluded directory basenames. The default exclusions are `.git` and `node_modules`; traversal does not follow directory symlinks or interpret ignore files. -Selecting a file changes only the editor text. The submitted user message retains the natural `@path` spelling and carries no injected contents, hidden context, or reference object. When the model-facing `read` tool is registered, the TUI contributes a stable system-prompt section that identifies `@` paths as explicit user references, directs the model to call `read` when contents are needed, and forbids claiming inspection before that call. Tool results invalidate the reusable fuzzy index so subsequent interactions observe likely workspace mutations. +Selecting a file changes only the editor text. The submitted user message retains the natural `@path` spelling and carries no injected contents, hidden context, or reference object. When the model-facing `read` tool is registered, the local provider contributes a stable system-prompt section that identifies `@` paths as explicit user references, directs the model to call `read` when contents are needed, and forbids claiming inspection before that call. Tool results invalidate the reusable fuzzy index so subsequent interactions observe likely workspace mutations. Structured session mentions keep their existing snapshot preparation. Unlike files, a referenced session has no general model-facing retrieval tool, so reducing `@session` to a path-like label would make its content unreachable. @@ -24,10 +24,10 @@ Structured session mentions keep their existing snapshot preparation. Unlike fil **Use the filesystem service's ordinary directory-list operation for discovery.** That seam is optimized for exact model-facing filesystem operations and may represent a remote namespace; recursive fuzzy indexing would multiply provider round trips and couple editor latency to tool policy. Host-side discovery keeps the terminal interaction local, while the documented namespace-alignment limitation remains explicit for non-local deployments. -**Add a new cross-package file-search capability.** The TUI is the only current consumer and the behavior is editor presentation rather than a model capability, so a new interface, implementation, and consumer package set would split the seam prematurely. +**Add a cross-package file-search capability before another consumer exists.** Rejected for the original TUI-only implementation because it would have split the seam prematurely. Web is now a second current consumer across a process boundary, so the later [Web reference decision](2026-07-27-web-file-and-session-references.md) introduces the interface / local implementation / consumer split and preserves this note's path-only semantics. ## Consequences Users can discover and insert paths without making selection itself expensive or model-visible beyond the path. The model preserves agency over whether to inspect a file, and any inspection remains reconstructable through the logged tool transcript. The fixed instruction slightly enlarges TUI system prompts when `read` is present, and content-requiring requests take an additional tool round trip. -Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align the TUI host working directory with the `read` namespace or supply a different completion surface. Package tests pin token grammar, ranking, bounds, cancellation, invalidation, and path-only submission; terminal snapshots and the real Loader PTY smoke pin the visible menu and keyboard completion. +Completion is deliberately bounded and advisory: very large workspaces may omit paths beyond the configured index cap, ignored files may still appear, and remote or virtual filesystem deployments must align completion with the `read` namespace or supply a different provider. Shared-package tests pin token grammar, ranking, bounds, cancellation, invalidation, and path-only submission; terminal snapshots, the Web snapshot, and the real Loader PTY smoke pin the visible completion flows. diff --git a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md index 410f0d49db..60dbfb577b 100644 --- a/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.zh.md @@ -10,9 +10,9 @@ TUI 提供结构化的 `@session` 引用,但用户在编辑提示词时无法 ## 决策 -TUI 维护一个有容量上限且可取消的主机工作区路径索引,以活跃会话的工作目录为根。在 token 边界输入 `@` 会对文件和目录进行模糊匹配;查询包含 `/` 时会直接列出指定目录,接受目录后会继续补全,包含空白的路径采用 `@"path with spaces"` 形式。配置项控制结果数量、索引大小以及排除的目录基名。默认排除 `.git` 和 `node_modules`;遍历既不跟随目录符号链接,也不解析忽略文件。 +共享的 `@deepseek-ai/dsh-file-reference-local` 提供方维护一个有容量上限且可取消的宿主工作区路径索引,以每个活跃会话的工作目录为根。TUI 直接消费其搜索和语法实现,Web 则通过宿主 API 使用同一功能,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。在 token 边界输入 `@` 会对文件和目录进行模糊匹配;查询包含 `/` 时会直接列出指定目录,接受目录后会继续补全,包含空白的路径采用 `@"path with spaces"` 形式。配置项控制结果数量、索引大小以及排除的目录基名。默认排除 `.git` 和 `node_modules`;遍历既不跟随目录符号链接,也不解析忽略文件。 -选择文件只会改变编辑器文本。提交的用户消息保留自然的 `@path` 写法,不携带注入的内容、隐藏上下文或引用对象。注册面向模型的 `read` 工具时,TUI 会加入一个稳定的系统提示词段,说明 `@` 路径是用户的显式引用,指示模型在需要内容时调用 `read`,并禁止模型在调用前声称已检查文件。工具结果会使可复用的模糊索引失效,后续交互因而能看到工作区中可能发生的变更。 +选择文件只会改变编辑器文本。提交的用户消息保留自然的 `@path` 写法,不携带注入的内容、隐藏上下文或引用对象。注册面向模型的 `read` 工具时,本地提供方会加入一个稳定的系统提示词段,说明 `@` 路径是用户的显式引用,指示模型在需要内容时调用 `read`,并禁止模型在调用前声称已检查文件。工具结果会使可复用的模糊索引失效,后续交互因而能看到工作区中可能发生的变更。 结构化会话提及保留现有的快照准备方式。与文件不同,被引用的会话没有通用的模型侧检索工具;如果把 `@session` 简化为类似路径的标签,模型将无法获取其内容。 @@ -24,10 +24,10 @@ TUI 维护一个有容量上限且可取消的主机工作区路径索引,以 **使用文件系统服务的常规目录列表操作进行发现。** 该 seam 针对面向模型的准确文件系统操作进行了优化,并且可能表示远程命名空间;递归模糊索引会增加提供方往返次数,并使编辑器延迟与工具策略耦合。主机侧发现让终端交互保留在本地,同时文档仍明确说明非本地部署中的命名空间对齐限制。 -**新增跨包的文件搜索功能。** TUI 是目前唯一的消费方,而且该行为属于编辑器呈现而非模型功能;新增一组接口、实现和消费方包会过早拆分这条 seam。 +**在出现另一个消费方之前新增跨包的文件搜索功能。** 原始实现只有 TUI 消费,因此不予采纳:该方案会过早拆分这条 seam。Web 现已成为跨进程边界的第二个当前消费方,因此后续的 [Web 引用决策](2026-07-27-web-file-and-session-references.md)引入接口/本地实现/消费方拆分,并保留本记录仅使用路径的语义。 ## 影响 用户可以发现并插入路径,而选择操作本身不会带来高开销,对模型可见的内容也仅限路径。模型仍可自行决定是否检查文件,任何检查都能通过已记录的工具 transcript 重建。存在 `read` 时,固定指令会略微增大 TUI 系统提示词;需要文件内容的请求还会增加一次工具往返。 -补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让 TUI 的主机工作目录与 `read` 命名空间对齐,否则需要提供不同的补全接口。包(package)测试固定 token 语法、排序、边界、取消、失效和仅提交路径的行为;终端快照与真实 Loader PTY 冒烟测试固定可见菜单和键盘补全。 +补全有意采用有界的提示性设计:超大型工作区可能省略超过配置索引上限的路径,被忽略的文件仍可能出现,远程或虚拟文件系统部署必须让补全与 `read` 命名空间对齐,否则需要提供不同的提供方。共享包(package)测试固定 token 语法、排序、边界、取消、失效和仅提交路径的行为;终端快照、Web 快照与真实 Loader PTY 冒烟测试固定可见的补全流程。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml new file mode 100644 index 0000000000..0c8290a540 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.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-07-27-web-file-and-session-references.md +2026-07-27-web-file-and-session-references.md: 71648a6ddbcffc7e700db1b0ce2135bf157e2bab +2026-07-27-web-file-and-session-references.zh.md: 05c0896700ae55c64b94c0b37a00a38127c97f1f diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md new file mode 100644 index 0000000000..71648a6ddb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -0,0 +1,49 @@ +# Agent Note: Web file and session references + +Status: implemented + +English | [中文](2026-07-27-web-file-and-session-references.zh.md) + +## Problem + +The Web composer had a reusable slash/reference trigger pipeline, but its `@` source was inert subagent-label text. The TUI already offered workspace-path discovery and structured cross-session snapshots, so Web needed the same user semantics without scanning the Host filesystem in the browser, binding session identity to a display label, or clearing a draft before Host-side snapshot preparation succeeded. + +## Decision + +Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Host lookups concurrently and preserves the TUI ordering of files before sessions; non-selectable `文件与文件夹` and `Session 对话` headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other. + +The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the Host RPC. The TUI imports the same search and grammar implementation instead of retaining a private copy. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash. + +A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferences.prepare()` before enqueue, then passes the prepared content and contexts in one agent operation. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure enqueue nothing. + +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or RPC failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders each metadata-confirmed session label as a reference chip even when following text is adjacent, plus a compact session-source summary instead of the snapshot JSON baked into model content. + +## Reference transaction + +```text +type @ → parallel file/session RPCs → pick path text or canonical session chip + → serialize draft → Host parses and prepares all sessions → enqueue once + ↘ any pre-enqueue failure: retain the unchanged editable draft +``` + +File lookup is advisory and cancellable; selection itself performs no read. Session preparation is authoritative and all-or-nothing because the source snapshot must be fixed before the target inbox accepts the message. + +## Alternatives considered + +**Keep file completion TUI-private.** Rejected after Web became a second current consumer; duplicate grammar, ranking, bounds, and invalidation would drift, while browser-side code cannot safely access the Host workspace. + +**Scan files through ordinary filesystem-tool RPCs.** Rejected because recursive fuzzy discovery is editor latency work, not a model-facing exact filesystem operation, and would couple the menu to tool policy and provider round trips. + +**Eagerly attach selected file contents.** Rejected because selection would spend context before relevance is known and bypass the logged, auditable `read` call/result sequence. + +**Represent sessions as plain `@label` text.** Rejected because labels are neither stable nor unique and cannot identify the source snapshot. Canonical Host-produced mentions preserve opaque session identity while keeping a readable display. + +**Clear the composer before the RPC settles.** Rejected because a failed preparation would lose the only editable copy of the request and visually claim acceptance that never occurred. + +## Verification + +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-text reference projection, codec round-trip, Host wire validation, all-or-nothing prompt preparation, and draft retention across serialization and RPC failures. The keyless assembled Web snapshot renders the available reference sections, selects a directory and file, then selects a session reference through the real client composition. + +## Consequences + +Web and TUI now share `@file` discovery semantics and the same structured session-reference identity, while Host services remain the authority for filesystem and session access. The new file-reference seam adds two packages and one Host RPC domain, but keeps browser bundles free of Node APIs and permits another provider to align completion with a remote filesystem. Candidate lookup failures remain quiet menu degradation; submission failures remain explicit and recoverable. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md new file mode 100644 index 0000000000..05c0896700 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -0,0 +1,49 @@ +# Agent Note: Web 文件与会话引用 + +Status: implemented + +[English](2026-07-27-web-file-and-session-references.md) | 中文 + +## 问题 + +Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 `@` source 只是不会产生实际作用的 subagent 标签文本。TUI 已经提供工作区路径发现和结构化跨会话快照,因此 Web 需要提供相同的用户语义,同时避免在浏览器中扫描宿主文件系统、把会话身份绑定到显示标签,或者在宿主侧快照准备成功前清除草稿。 + +## 决策 + +Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项宿主查询,并保留 TUI 中文件排在会话之前的顺序;不可选择的 `文件与文件夹` 和 `Session 对话` 标题会区分两个连续的候选分组,且不会进入键盘选择索引。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。 + +文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费宿主 RPC。TUI 直接导入同一套搜索和语法实现,不再保留私有副本。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。 + +选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在入队前调用 `ctx.sessionReferences.prepare()`,随后以一次 agent 操作传入准备后的内容和上下文。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会让消息入队。 + +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 RPC 失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把元数据确认的每个会话标签渲染为引用 chip,即使后续文本与标签直接相邻也如此,并显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 + +## 引用事务 + +```text +type @ → parallel file/session RPCs → pick path text or canonical session chip + → serialize draft → Host parses and prepares all sessions → enqueue once + ↘ any pre-enqueue failure: retain the unchanged editable draft +``` + +文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备具有权威性,并且必须全有或全无,因为目标收件箱接受消息前必须固定源快照。 + +## 备选方案 + +**文件补全仅保留在 TUI 内部。** Web 成为第二个当前消费方后不予采纳:重复的语法、排序、边界和失效处理会产生偏差,而且浏览器侧代码无法安全访问宿主工作区。 + +**通过普通文件系统工具 RPC 扫描文件。** 不予采纳,因为递归模糊发现属于编辑器低延迟工作,而不是面向模型的精确文件系统操作;该方案还会把菜单与工具策略及提供方往返绑定。 + +**选择文件时立即附加其内容。** 不予采纳,因为该方案会在尚未确定相关性时消耗上下文,并绕过可从日志重建、可审计的 `read` 调用/结果序列。 + +**用普通 `@label` 文本表示会话。** 不予采纳,因为标签既不稳定也不唯一,无法标识源快照。宿主生成的规范提及标记既能保留不透明会话身份,也能保持显示内容易读。 + +**RPC 完成前清空输入框。** 不予采纳,因为准备失败会丢失请求唯一可编辑的副本,并在视觉上错误表示一个从未成功的接受操作。 + +## 验证 + +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻文本条件下的引用投影、codec 无损往返、宿主协议校验、全有或全无的提示词准备,以及在序列化和 RPC 失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择目录、文件和会话引用。 + +## 后果 + +Web 与 TUI 现在共享 `@file` 发现语义和同一套结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。新的文件引用 seam 增加了两个包和一个宿主 RPC 领域,但浏览器 bundle 中不包含 Node API,并允许其他提供方让补全与远程文件系统对齐。候选查询失败仍会让菜单静默降级;提交失败仍会显式报告且可恢复。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index efd75c1cf5..9b450539d0 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -89,6 +89,17 @@ config: root: './.sessions' +- id: session-query-sqlite + name: '@deepseek-ai/dsh-session-query-sqlite' + config: + path: './.sessions/session-query.db' + +- id: session-reference + name: '@deepseek-ai/dsh-session-reference' + +- id: file-reference-local + name: '@deepseek-ai/dsh-file-reference-local' + - id: storage name: '@deepseek-ai/dsh-storage' @@ -287,7 +298,7 @@ name: '@deepseek-ai/dsh-client-ui-workspace' # Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over -# it (ui-command), and the two reference sources (ui-skill / ui-subagent). +# it (ui-command), and the two reference sources (ui-skill / ui-reference). - id: ui-slash name: '@deepseek-ai/dsh-client-ui-slash' @@ -297,8 +308,8 @@ - id: ui-skill name: '@deepseek-ai/dsh-client-ui-skill' -- id: ui-subagent - name: '@deepseek-ai/dsh-client-ui-subagent' +- id: ui-reference + name: '@deepseek-ai/dsh-client-ui-reference' - id: ui-question name: '@deepseek-ai/dsh-client-ui-question' diff --git a/apps/cli/package.json b/apps/cli/package.json index e0a3a51c94..1b1da98ca7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -36,7 +36,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@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-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", @@ -46,6 +46,8 @@ "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -55,6 +57,8 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/apps/web/tests/slash-flow.snapshot.ts b/apps/web/tests/slash-flow.snapshot.ts index 29c1d68f7a..8dea83e20b 100644 --- a/apps/web/tests/slash-flow.snapshot.ts +++ b/apps/web/tests/slash-flow.snapshot.ts @@ -2,13 +2,13 @@ // Assembled keyless snapshot of the slash/input/session convergence under the // agent-parity model: the New Session view state locks the composer until a // Workspace is picked (connectWorkspace materializes the full Session+Agent), -// the '/' menu serves the session's wire command catalog (sessions are always -// agent-backed — no draft/materialized split), a leadingInput command claims, -// submits over the wire, and notices its result, and the SAME composer -// textarea then carries the first plain send, whose ACCEPTANCE (not attempt) -// flips blank and surfaces the session in lists. This is the user-visible -// acceptance anchor — package mocks do not substitute for the assembled -// application transcript. +// the '@' menu descends a Host-backed file directory, the '/' menu serves the +// session's wire command catalog (sessions are always agent-backed — no +// draft/materialized split), a leadingInput command claims, submits over the +// wire, and notices its result, and the SAME composer textarea then carries +// the first plain send, whose ACCEPTANCE (not attempt) flips blank and +// surfaces the session in lists. This is the user-visible acceptance anchor — +// package mocks do not substitute for the assembled application transcript. import { readFileSync } from 'node:fs' import { join } from 'node:path' import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react' @@ -27,7 +27,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout', '@deepseek-ai/dsh-client-ui-slash'] }, { id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-skill', dir: 'ui-skill', url: '/plugins/ui-skill.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] }, - { id: '@deepseek-ai/dsh-client-ui-subagent', dir: 'ui-subagent', url: '/plugins/ui-subagent.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash'] }, + { id: '@deepseek-ai/dsh-client-ui-reference', dir: 'ui-reference', url: '/plugins/ui-reference.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-slash'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', @@ -114,7 +114,7 @@ async function typeComposer(composer: HTMLTextAreaElement, value: string): Promi await waitFor(() => { expect(composer.value).toBe(value) }) } -it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on-acceptance ride one resident composer', async () => { +it('locked view state, connectWorkspace unlock, @file and /echo chains, and blank-on-acceptance ride one resident composer', async () => { boot('?fixture=empty') // View state: no session entity — the composer renders locked; only the @@ -141,6 +141,23 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on- ) expect(composer.disabled).toBe(false) + // '@' combines Host-backed references. Picking a directory keeps + // completion open at its trailing slash; picking a file closes it with a + // separator so ordinary prompt text can continue. + await typeComposer(composer, '@') + const referenceMenu = await screen.findByRole('listbox', { name: 'Trigger suggestions' }) + await waitFor(() => { expect(visibleText(referenceMenu)).toContain('Folder · notes/') }) + const referenceSections = [ + within(referenceMenu).getByText('文件与文件夹').textContent, + ] + fireEvent.mouseDown(screen.getByRole('option', { name: /Folder · notes\// })) + await waitFor(() => { expect(composer.value).toBe('@notes/') }) + const nestedFile = await screen.findByRole('option', { name: /File · demo\.txt/ }) + fireEvent.mouseDown(nestedFile) + await waitFor(() => { expect(composer.value).toBe('@notes/demo.txt ') }) + const filePathCompleted = composer.value + await typeComposer(composer, '') + // '/' opens the menu with the session's wire command catalog (the session // is agent-backed from birth — the catalog is the single-address list). await typeComposer(composer, '/') @@ -178,14 +195,57 @@ it('locked view state, connectWorkspace unlock, /echo claim chain, and blank-on- expect({ menuHadEcho: menuText.includes('echo'), menuHadCompact: menuText.includes('compact'), + referenceSections, + filePathCompleted, composerSurvivedConversion: after === before, sessionListed: visibleText(within(tree).getByText('1 session').closest('[role="treeitem"]')!), }).toMatchInlineSnapshot(` { "composerSurvivedConversion": true, + "filePathCompleted": "@notes/demo.txt ", "menuHadCompact": true, "menuHadEcho": true, + "referenceSections": [ + "文件与文件夹", + ], "sessionListed": "nova1 session", } `) }) + +it('the assembled @ menu inserts a session candidate as one atomic chip', async () => { + boot('?fixture') + const composer = await screen.findByPlaceholderText( + 'Describe what you want to build', {}, { timeout: 10_000 }, + ) + await typeComposer(composer, '@') + const menu = await screen.findByRole('listbox', { name: 'Trigger suggestions' }) + await waitFor(() => { + expect(visibleText(menu)).toContain('Session · Fixture child session') + }) + const referenceSections = [ + within(menu).getByText('文件与文件夹').textContent, + within(menu).getByText('Session 对话').textContent, + ] + fireEvent.mouseDown(screen.getByRole('option', { name: /Session · Fixture child session/ })) + await waitFor(() => { + expect(composer.value).toBe('\uFFFC') + }) + const chip = document.querySelector('[data-decoration="chip"]') + expect({ + atomicDraftLength: composer.value.length, + chipLabel: chip?.title, + menuClosed: screen.queryByRole('listbox', { name: 'Trigger suggestions' }) === null, + referenceSections, + }).toMatchInlineSnapshot(` + { + "atomicDraftLength": 1, + "chipLabel": "@Fixture child session", + "menuClosed": true, + "referenceSections": [ + "文件与文件夹", + "Session 对话", + ], + } + `) +}) diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2da9652c59..2e49ce9289 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -47,6 +47,9 @@ flowchart LR svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] + pkg_file_reference["file-reference"] + svc_fileReferences["ctx.fileReferences
Workspace file-reference discovery"] + pkg_file_reference_local["file-reference-local"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] pkg_tui["tui"] pkg_session_title["session-title"] @@ -152,6 +155,8 @@ flowchart LR pkg_compact --> svc_compact pkg_compact_basic --> svc_compact pkg_compact_tool_result_prune --> svc_toolResultPrune + pkg_file_reference --> svc_fileReferences + pkg_file_reference_local --> svc_fileReferences pkg_fs --> svc_fs pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs @@ -224,6 +229,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_commands --> pkg_tui svc_compact --> pkg_compact_basic + svc_fileReferences --> pkg_apiproxy svc_fs --> pkg_tool_fs svc_httpServer --> pkg_connection svc_httpServer --> pkg_hmr @@ -248,6 +254,7 @@ flowchart LR svc_sessionPersistence --> pkg_tool_bash svc_sessionQuery --> pkg_session_reference svc_sessionQuery --> pkg_tool_session_query + svc_sessionReferences --> pkg_apiproxy svc_sessionReferences --> pkg_tui svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop @@ -305,7 +312,8 @@ flowchart LR | `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.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) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | `apiproxy` | - | The local provider owns one invalidated path index per agent; Host RPC projects its cancellable path candidates to browser reference sources. | +| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | `apiproxy`, [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; Host and TUI adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a31c353fd..2a989012ed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -380,6 +380,24 @@ export interface ToolResultPruneConfig { Source: [`packages/compact/compact-tool-result-prune/src/types.ts:4`](../packages/compact/compact-tool-result-prune/src/types.ts) +## `@deepseek-ai/dsh-file-reference-local` + +Requires: `agents` + +```ts config-catalog +/** Local file-reference discovery configuration. */ +export interface Config { + /** Maximum ranked candidates returned for one query. */ + maxResults?: number + /** Maximum indexed files and directories per agent workspace. */ + maxEntries?: number + /** Directory basenames never traversed or offered. */ + excludedDirectories?: string[] +} +``` + +Source: [`packages/context/file-reference-local/src/index.ts:35`](../packages/context/file-reference-local/src/index.ts) + ## `@deepseek-ai/dsh-fs-local` ```ts config-catalog @@ -1781,7 +1799,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:269`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` @@ -2052,12 +2070,12 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` — requires `tools` · `userInteraction` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts)) - `@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-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) - `@deepseek-ai/dsh-client-ui-workspace` ([`packages/client/ui-workspace/src/index.ts`](../packages/client/ui-workspace/src/index.ts)) @@ -2086,6 +2104,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) +- `@deepseek-ai/dsh-file-reference` — abstract `FileReferenceService` ([`packages/context/file-reference/src/index.ts`](../packages/context/file-reference/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ccae4c9f9b..dc24ca01cd 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -641,7 +641,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:71`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -662,7 +662,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:89`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:81`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -685,7 +685,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:101`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:93`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -706,7 +706,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:111`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:103`](../../packages/core/session/src/index.ts) ## `slash/*` @@ -726,7 +726,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:228`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-consume-token` — bail @@ -742,7 +742,7 @@ Consumes one command token after business success (popup settle / menu-pick exec 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-insert-reference` — bail @@ -758,7 +758,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:235`](../../packages/client/ui-slash/src/types.ts) ### `slash/input-insert-text` — bail @@ -775,7 +775,7 @@ Replaces the trigger token span with literal text — the plain-text reference p 'slash/input-insert-text'(request: InsertTextRequest): true | undefined ``` -Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts) +Source: [`packages/client/ui-slash/src/types.ts:250`](../../packages/client/ui-slash/src/types.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index cc21210f3b..654bd30b74 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -469,6 +469,25 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts) +## `ctx.fileReferences` — `FileReferenceService` (abstract seam) + +Host capability for cancellable file-reference discovery. + +```ts cordis-catalog +/** + * List file and directory candidates for one agent's working directory. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/context/file-reference/src/index.ts:32`](../../packages/context/file-reference/src/index.ts) + ## `ctx.fs` — `FileSystem` (abstract seam) Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. @@ -1342,7 +1361,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:606`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:598`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index ced431bf51..63b41bfe0e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,8 +9,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`file-reference-local`](../packages/context/file-reference-local), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy` | @@ -34,14 +34,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../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-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../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), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | -| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../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), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:228`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:235`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | +| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:250`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../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:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 69bcdfd4ee..4a09034bac 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -147,13 +147,13 @@ flowchart TD pkg_client_ui_models["client-ui-models"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] + pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_sidebar["client-ui-sidebar"] pkg_client_ui_skill["client-ui-skill"] pkg_client_ui_slash["client-ui-slash"] pkg_client_ui_slots["client-ui-slots"] - pkg_client_ui_subagent["client-ui-subagent"] pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_trajectory["client-ui-trajectory"] pkg_client_ui_workspace["client-ui-workspace"] @@ -165,6 +165,8 @@ flowchart TD pkg_code_runtime_worker["code-runtime-worker"] end subgraph group_context["packages/context"] + pkg_file_reference["file-reference"] + pkg_file_reference_local["file-reference-local"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_workspace_context["workspace-context"] @@ -271,9 +273,6 @@ flowchart TD pkg_client_ui_sidebar --> pkg_client_ui_primitives pkg_client_ui_sidebar --> pkg_client_ui_slots pkg_client_ui_sidebar --> pkg_invariants - pkg_client_ui_slash --> pkg_client_runtime - pkg_client_ui_slash --> pkg_client_ui_slots - pkg_client_ui_slash --> pkg_invariants pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots @@ -304,26 +303,12 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm - pkg_client_ui_conversation --> pkg_client_runtime - pkg_client_ui_conversation --> pkg_client_ui_primitives - pkg_client_ui_conversation --> pkg_client_ui_slash - pkg_client_ui_conversation --> pkg_client_ui_slots - pkg_client_ui_conversation --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_locale pkg_client_ui_settings_general --> pkg_client_runtime pkg_client_ui_settings_general --> pkg_client_ui_primitives pkg_client_ui_settings_general --> pkg_client_ui_settings pkg_client_ui_settings_general --> pkg_client_ui_slots pkg_client_ui_settings_general --> pkg_invariants - pkg_client_ui_skill --> pkg_client_connection - pkg_client_ui_skill --> pkg_client_runtime - pkg_client_ui_skill --> pkg_client_ui_slash - pkg_client_ui_skill --> pkg_client_ui_slots - pkg_client_ui_skill --> pkg_invariants - pkg_client_ui_subagent --> pkg_client_runtime - pkg_client_ui_subagent --> pkg_client_ui_slash - pkg_client_ui_subagent --> pkg_client_ui_slots - pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_theme --> pkg_client_locale pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives @@ -381,13 +366,6 @@ flowchart TD pkg_app_boot --> pkg_invariants pkg_app_boot --> pkg_paths pkg_app_boot --> pkg_system_prompt - pkg_client_ui_command --> pkg_client_connection - pkg_client_ui_command --> pkg_client_runtime - pkg_client_ui_command --> pkg_client_ui_conversation - pkg_client_ui_command --> pkg_client_ui_primitives - pkg_client_ui_command --> pkg_client_ui_slash - pkg_client_ui_command --> pkg_client_ui_slots - pkg_client_ui_command --> pkg_invariants pkg_client_ui_layout --> pkg_client_runtime pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme @@ -470,6 +448,8 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -543,6 +523,10 @@ flowchart TD pkg_permission --> pkg_sandbox_policy pkg_permission --> pkg_session pkg_permission --> pkg_user_approval + pkg_client_ui_slash --> pkg_client_runtime + pkg_client_ui_slash --> pkg_client_ui_slots + pkg_client_ui_slash --> pkg_file_reference + pkg_client_ui_slash --> pkg_invariants pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -675,6 +659,26 @@ flowchart TD pkg_tool_ask_user --> pkg_invariants pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction + pkg_client_ui_conversation --> pkg_client_runtime + pkg_client_ui_conversation --> pkg_client_ui_primitives + pkg_client_ui_conversation --> pkg_client_ui_slash + pkg_client_ui_conversation --> pkg_client_ui_slots + pkg_client_ui_conversation --> pkg_invariants + pkg_client_ui_reference --> pkg_client_connection + pkg_client_ui_reference --> pkg_client_runtime + pkg_client_ui_reference --> pkg_client_ui_slash + pkg_client_ui_reference --> pkg_file_reference + pkg_client_ui_reference --> pkg_invariants + pkg_client_ui_skill --> pkg_client_connection + pkg_client_ui_skill --> pkg_client_runtime + pkg_client_ui_skill --> pkg_client_ui_slash + pkg_client_ui_skill --> pkg_client_ui_slots + pkg_client_ui_skill --> pkg_invariants + pkg_file_reference_local --> pkg_agent + pkg_file_reference_local --> pkg_file_reference + pkg_file_reference_local --> pkg_invariants + pkg_file_reference_local --> pkg_system_prompt + pkg_file_reference_local --> pkg_tools pkg_workspace_context --> pkg_agent pkg_workspace_context --> pkg_fs pkg_workspace_context --> pkg_invariants @@ -751,6 +755,7 @@ flowchart TD pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_commands + pkg_tui --> pkg_file_reference_local pkg_tui --> pkg_goal pkg_tui --> pkg_invariants pkg_tui --> pkg_llm @@ -765,6 +770,13 @@ flowchart TD pkg_tui --> pkg_token_meter pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction + pkg_client_ui_command --> pkg_client_connection + pkg_client_ui_command --> pkg_client_runtime + pkg_client_ui_command --> pkg_client_ui_conversation + pkg_client_ui_command --> pkg_client_ui_primitives + pkg_client_ui_command --> pkg_client_ui_slash + pkg_client_ui_command --> pkg_client_ui_slots + pkg_client_ui_command --> pkg_invariants pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_goal @@ -882,7 +894,6 @@ flowchart TD | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | @@ -894,10 +905,7 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`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-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -916,7 +924,6 @@ flowchart TD | [`session-title`](../packages/session-title/session-title) | `session-title` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/ui/app-boot) | `ui` | [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`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-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`timeout`](../packages/util/timeout) | @@ -938,6 +945,7 @@ flowchart TD | [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | @@ -954,6 +962,7 @@ flowchart TD | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`file-reference`](../packages/context/file-reference), [`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) | | [`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) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | @@ -975,6 +984,10 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`client-runtime`](../packages/client/runtime), [`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-reference`](../packages/client/ui-reference) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/support/invariants) | +| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`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) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`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) | @@ -987,7 +1000,8 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`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) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index edc5b2e25d..effeca6f8e 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -10,6 +10,7 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, + ReferencesApi, FileReferenceItem, SessionReferenceItem, } 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/fixture.ts b/packages/client/connection/src/client/fixture.ts index eaab0a43f9..a09a9a75a8 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -798,6 +798,36 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) }, }, + references: { + files: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const query = request.payload.query.toLocaleLowerCase() + const items = [ + { path: 'notes', kind: 'directory' as const }, + { path: 'README.md', kind: 'file' as const }, + { path: 'notes/demo.txt', kind: 'file' as const }, + ].filter(item => item.path.toLocaleLowerCase().includes(query)) + return ok(request, { items }) + }, + sessions: (request) => { + const missing = requireSession(request) + if (missing !== undefined) return missing + const query = request.payload.query.toLocaleLowerCase() + const items = sessions + .filter(item => item.sessionId !== request.payload.sessionId) + .filter(item => String(item.sessionId).toLocaleLowerCase().includes(query) + || item.cwd?.toLocaleLowerCase().includes(query) === true) + .map(item => ({ + sessionId: item.sessionId, + label: item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId), + ...item.cwd === undefined ? {} : { cwd: item.cwd }, + createdAt: item.updatedAt, + mention: `@[${item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId)}](dsh-session:${btoa(JSON.stringify(item.sessionId)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '')})`, + })) + return ok(request, { items }) + }, + }, events: { async *mux(_request, signal) { const conn = new FxInbox() @@ -919,6 +949,8 @@ export class FixtureApiClient extends AbstractApiClient { // The in-memory execute never blocks, so a never-aborting signal is faithful here. case 'command.execute': return this.api.commands.execute(request, new AbortController().signal) case 'skill.list': return this.api.skills.list(request) + case 'reference.files': return this.api.references.files(request) + case 'reference.sessions': return this.api.references.sessions(request) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index d4505eb659..f249153287 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -15,6 +15,7 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, + ReferencesApi, FileReferenceItem, SessionReferenceItem, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index bf7295cc50..7c365f88c0 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -104,6 +104,11 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } + readonly references: IApiClient['references'] = { + files: (payload: unknown) => this.record('reference.files', payload, Promise.resolve(ok({ items: [] }))), + sessions: (payload: unknown) => this.record('reference.sessions', payload, Promise.resolve(ok({ items: [] }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 49ae8634ec..f4ddf6c5b5 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,6 +4,7 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { PromptPrefixContext } from '@deepseek-ai/dsh-session/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' @@ -48,6 +49,8 @@ export interface UserMessageNode { time: number content: readonly ContentBlock[] source: unknown + /** Model-hidden descriptors for contexts baked ahead of this direct prompt. */ + prefixContexts?: readonly PromptPrefixContext[] } /** A finalized (or interruption-frozen) assistant message. */ @@ -74,6 +77,8 @@ export interface SteeringMessageNode { turn: number content: readonly ContentBlock[] source: unknown + /** Model-hidden descriptors for contexts baked ahead of this direct prompt. */ + prefixContexts?: readonly PromptPrefixContext[] } /** A context/system injection surfaced in the flow. */ diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 0f40d9bf2a..1a9a983b93 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -8,6 +8,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' // go through it — the package root points at lib/index.js (needs a build) which the vite // browser bundle cannot resolve; surface.ts has no Node dependencies. import { SurfaceManager, isSurfaceEligibleType } from '@deepseek-ai/dsh-session/surface' +import { displayPromptContent } from '@deepseek-ai/dsh-session/display' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { ConversationNode } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' @@ -51,7 +52,10 @@ function materializeNode( } return { kind: 'user', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, + content: displayPromptContent(event.data), source: event.data.source, + ...event.data.envelope === undefined + ? {} + : { prefixContexts: event.data.envelope.prefixContexts }, } case 'assistant/message': return { @@ -62,7 +66,10 @@ function materializeNode( case 'steering/message': return { kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, - content: event.data.content, source: event.data.source, + content: displayPromptContent(event.data), source: event.data.source, + ...event.data.envelope === undefined + ? {} + : { prefixContexts: event.data.envelope.prefixContexts }, } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 75c55bc4bd..6a701f391d 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -173,9 +173,14 @@ export class Session implements ObservableSnapshot { * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. * @param content - core content blocks verbatim. * @param mode - queue appends after the current turn; steer interrupts it. + * @param signal - optional cancellation for Host-side pre-enqueue preparation. * @returns the prompt result (also mirrored into promptError on failure). */ - async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> { + async prompt( + content: ContentBlock[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> { this.promptError = null this.lastAgentError = null // Synchronous, before the first await: the blank → engaging edge must be @@ -185,7 +190,7 @@ export class Session implements ObservableSnapshot { this.notifier.markDirty() let result: RpcResult<{ accepted: true }> try { - result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result + result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content }, signal)).result } catch (error) { result = transportError(error) } diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index dcb334f6ea..42c1e8256c 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -126,6 +126,11 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } + readonly references: IApiClient['references'] = { + files: (payload: unknown) => this.record('reference.files', payload, Promise.resolve(ok({ items: [] }))), + sessions: (payload: unknown) => this.record('reference.sessions', payload, Promise.resolve(ok({ items: [] }))), + } + /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ suppressStreamOpen = false diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index bb360e2a67..dab719cc4e 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -55,6 +55,39 @@ describe('FoldAdapter', () => { expect(result).toMatchObject({ callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false }) }) + it('replays only the direct prompt while retaining referenced-session descriptors', () => { + const adapter = new FoldAdapter() + const prefixContexts = [{ + source: { kind: 'plugin', plugin: 'session-reference' }, + meta: { + kind: 'session-reference', + version: 1, + references: [{ sessionId: 'source', label: 'Research' }], + }, + }] + adapter.reset([at(0, { + type: 'user/message', + surfaceOp: 'append', + data: { + content: [ + { type: 'text', text: 'snapshot' }, + { type: 'text', text: '\n\n## My request:\n' }, + { type: 'text', text: 'compare @Research' }, + ], + source: { kind: 'user' }, + envelope: { + displayContent: [{ type: 'text', text: 'compare @Research' }], + prefixContexts, + }, + }, + })], 0) + expect(adapter.nodes().nodes[0]).toMatchObject({ + kind: 'user', + content: [{ type: 'text', text: 'compare @Research' }], + prefixContexts, + }) + }) + it('returns call:null for a tool-result whose call fell outside the window', () => { const adapter = new FoldAdapter() adapter.reset([ev.toolResult(50, 3, 'outside-call', '孤儿结果')], 50) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index f5cf9a2138..d0fb4b53a7 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -219,17 +219,23 @@ describe('paging', () => { describe('prompt and cancel errors', () => { it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { const { api, session } = makeSession() + const prompt = vi.spyOn(api.sessions, 'prompt') + const controller = new AbortController() // The blank → engaging edge fires before the RPC settles: the first-send // flow reads the phase on the session area's first frame to keep the // guidance hero from flashing back in. expect(session.getSnapshot().composerPhase).toBe('blank') - const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue') + const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue', controller.signal) expect(session.getSnapshot().composerPhase).toBe('engaging') const result = await inFlight expect(result.ok).toBe(true) // Monotone: settlement alone does not step the phase anywhere. expect(session.getSnapshot().composerPhase).toBe('engaging') expect(api.callsOf('session.prompt')).toMatchObject([{ sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }]) + expect(prompt).toHaveBeenCalledWith( + { sessionId: SID, mode: 'queue', content: [{ type: 'text', text: '要发的' }] }, + controller.signal, + ) // First content lands (running turn): engaging → active. session.handleRunning(true) expect(session.getSnapshot().composerPhase).toBe('active') diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b93feae8b..766affc4b2 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -27,7 +27,7 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|file-reference|session|llm|tools|brand)(\/|$)/ /** * Documented TEMPORARY exemption, not a platform module (hence not in diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 8117d42ea4..b1440226e7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 -README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467 -README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311 +# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md +README.md: 8c807d8ab9c9fa4559c846876a99c373366369de +README.zh.md: 13ee84a45ecbba3c950beb4478ecb80b15b47032 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b9ec555f15..8c807d8ab9 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,6 +14,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to Per-session UI state (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. +Ordinary submission is a transaction between the input machine and its default sink. The composer retains its draft and atomic reference chips while serialization or `session.prompt` is pending, clears them only after Host acceptance, and restores the editable phase unchanged after rejection. Replay uses the session package's display projection for prompt envelopes; session-reference metadata projects its confirmed label as a reference chip even when following prompt text is adjacent, and adds a compact `引用会话` source summary below the direct user text instead of exposing the prepared snapshot JSON. + `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index b1e3c1f433..13ee84a45e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,6 +14,8 @@ 逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 +普通提交是输入状态机与默认 sink 之间的一项事务。在序列化或 `session.prompt` 等待完成期间,输入框会保留草稿和原子引用 chip;只有宿主接受后才会将它们清除,拒绝后则原样恢复可编辑阶段。回放对提示词封套使用会话包的显示投影;会话引用元数据会把已确认的标签投影为引用 chip,即使后续提示词文本与标签直接相邻也如此,并在直接用户文本下方添加精简的 `引用会话` 来源摘要,而不会暴露准备好的快照 JSON。 + `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index 047878f1d0..41e7cd3071 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -7,9 +7,17 @@ justify-content: flex-end; } +.userStack { + display: flex; + max-width: min(525px, 82%); + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ - max-width: min(525px, 82%); + max-width: 100%; background: var(--dsw-specific-bubble); border-radius: 22px; /* 44px single-line bubble: 24 line + 10 vertical padding each side. */ @@ -19,6 +27,12 @@ color: var(--dsw-alias-label-primary); } +.referenceSummary { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + .badge { display: inline-block; margin-bottom: 4px; @@ -33,8 +47,8 @@ padding: 2px 0; } -/* Reference chip projection inside a user bubble (`name` model - spans render as chips; free geometry — no textarea pairing here). */ +/* Reference-chip projection inside a user bubble; free geometry means the + textarea overlay's metric pairing does not apply here. */ .refChip { display: inline-block; margin: 0 2px; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index e79304fc19..bb935b62b3 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -27,17 +27,21 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown } /** - * Display projection of reference forms in a user bubble (free geometry — no - * textarea alignment constraint here); everything else stays plain text. The - * logged model text remains the single truth; this is presentation only. Two - * shapes decorate: legacy `name` spans (pre-decision-21 - * history) and plain-text `/name` / `@name` word-boundary tokens (decision - * 21: the sent text IS the reference — the bubble uses the same plainest - * token scan as the composer, minus the lexicon: sent tokens were validated - * at compose time, so shape alone decorates). + * Decorate legacy skill spans, boundary-delimited plain references, and exact + * metadata-confirmed session labels in the user bubble. Confirmed labels may + * touch following prompt text because their durable metadata disambiguates + * the reference boundary. Logged message text remains unchanged. */ -function projectUserText(text: string): ReactNode { - const re = /([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g +function projectUserText(text: string, sessionLabels: readonly string[]): ReactNode { + const exactSessions = [...new Set(sessionLabels)] + .filter(label => label.length > 0) + .sort((left, right) => right.length - left.length) + .map(label => label.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')) + const sessionPattern = exactSessions.length === 0 ? '' : `@(?:${exactSessions.join('|')})|` + const re = new RegExp( + `([^<]+)|(^|\\s)(${sessionPattern}[/@][\\w-]+(?=\\s|$))`, + 'gu', + ) const parts: ReactNode[] = [] let cursor = 0 let m: RegExpExecArray | null @@ -47,7 +51,7 @@ function projectUserText(text: string): ReactNode { const label = legacy ? `/${m[1]}` : m[3] ?? '' if (tokenStart > cursor) parts.push() parts.push( - + {label} , ) @@ -58,17 +62,42 @@ function projectUserText(text: string): ReactNode { return <>{parts} } +function referencedSessionLabels( + node: UserMessageNode | SteeringMessageNode, +): string[] { + const labels: string[] = [] + for (const context of node.prefixContexts ?? []) { + const meta = context.meta + if (typeof meta !== 'object' || meta === null || Array.isArray(meta) + || meta.kind !== 'session-reference' || !Array.isArray(meta.references)) continue + for (const reference of meta.references) { + if (typeof reference !== 'object' || reference === null || Array.isArray(reference)) continue + const label = typeof reference.label === 'string' + ? reference.label + : typeof reference.sessionId === 'string' ? reference.sessionId : undefined + if (label !== undefined) labels.push(label) + } + } + return labels +} + export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) { switch (node.kind) { case 'user': case 'steering': { const { text, rest } = contentText(node.content) + const referencedSessions = referencedSessionLabels(node) return (
-
- {node.kind === 'steering' && 插话} - {projectUserText(text)} - {rest.map((block, i) => )} +
+
+ {node.kind === 'steering' && 插话} + {projectUserText(text, referencedSessions)} + {rest.map((block, i) => )} +
+ {referencedSessions.length > 0 + ?
引用会话 · {referencedSessions.join(', ')}
+ : null}
) diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 8a4d2905db..ac4f216a8e 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -211,7 +211,7 @@ export interface InputState { export interface SubmitAttempt { readonly seq: number readonly signal: AbortSignal - /** Draft at enter time; rollback restores it only while the live draft still equals it. */ + /** Draft at enter time; settlement clears it only after acceptance. */ readonly draftSnapshot: string } @@ -250,11 +250,6 @@ export type InputEvent = | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } - /** - * An ordinary (default-sink) send was accepted: clear the draft as a COMMIT — - * undo must not resurrect sent content (mirrors submit-settled's success arm). - */ - | { readonly type: 'send-committed' } | { readonly type: 'release' } /** @@ -265,5 +260,5 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' } + | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: 'queue' | 'steer' } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index f3f6dd7451..8c9ca6c1c7 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -10,7 +10,7 @@ import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, - ReferenceInsert, SlashController, TokenSpan, + ReferenceInsert, SlashController, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' import type { EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, @@ -39,7 +39,7 @@ export interface SessionInputDeps { /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ - defaultSink(text: string, mode: 'queue' | 'steer'): void + defaultSink(text: string, mode: 'queue' | 'steer', signal: AbortSignal): Promise } /** Guard tier from the machine phase. */ @@ -97,15 +97,6 @@ export class SessionInputShell implements SessionInput { this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) })) } - /** - * Clear the draft as a successful-send commit: no undo unit is recorded and - * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content - * (the command path gets the same discipline from submit-settled success). - */ - commitSend(): void { - this.run(this.core.dispatch({ type: 'send-committed' })) - } - /** * Insert a newline at the selection as one machine transaction (the * execCommand path is gone — a second undo history would fork). @@ -336,7 +327,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.draft, fx.mode) + this.sinkSerialized(fx.attempt, fx.draft, fx.mode) return } default: @@ -351,10 +342,10 @@ export class SessionInputShell implements SessionInput { * send — notice + draft and chips retained, never a silent downgrade to * the clipboard text. Chip-free drafts skip the async detour. */ - private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void { + private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: 'queue' | 'steer'): void { const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.deps.defaultSink(draft.trim(), mode) + this.settleDefault(attempt, this.deps.defaultSink(draft.trim(), mode, attempt.signal)) return } const slash = this.deps.slash?.() @@ -374,13 +365,44 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + 1 } out += draft.slice(cursor) - this.deps.defaultSink(out.trim(), mode) + this.settleDefault(attempt, this.deps.defaultSink(out.trim(), mode, attempt.signal)) }, (error: unknown) => { controller.abort() if (this.disposed) return const message = error instanceof Error ? error.message : String(error) - this.notify('error', message) + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: false, + message, + })) + }, + ) + } + + private settleDefault( + attempt: SubmitAttempt, + pending: Promise, + ): void { + pending.then( + (outcome) => { + if (this.dead(attempt)) return + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: outcome.kind === 'success', + outcome, + })) + }, + (error: unknown) => { + if (this.dead(attempt)) return + this.run(this.core.dispatch({ + type: 'submit-settled', + attempt, + ok: false, + message: error instanceof Error ? error.message : String(error), + })) }, ) } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 2ae474be31..efb7ccb020 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -9,7 +9,7 @@ * real host entity, so the sink is one unconditional prompt path. */ import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { SlashController, SlashServiceContract, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import type {} from '@deepseek-ai/dsh-client-ui-slash/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' @@ -57,7 +57,7 @@ export class InputHub implements InputService { slash: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, mode) => { this.sink(session, text, mode) }, + defaultSink: (text, mode, signal) => this.sink(session, text, mode, signal), }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -70,8 +70,13 @@ export class InputHub implements InputService { shell.insertReference(req.reference, req.span) ? true : undefined), actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined), - actx.on('slash/input-insert-text', req => - shell.insertText(req.text, req.span) ? true : undefined), + actx.on('slash/input-insert-text', (req) => { + if (!shell.insertText(req.text, req.span)) return undefined + if (req.continue === true) { + shell.track(shell.snapshot.draft, req.span.start + req.text.length) + } + return true + }), ] return () => { for (const off of offs) off() @@ -108,24 +113,21 @@ export class InputHub implements InputService { } /** - * Default sink: optimistic clear + prompt. The session is always a real - * host entity (materialized when its workspace was picked), so there is - * exactly one path; a failed first prompt is an ordinary prompt failure - * (error strip via promptError, draft restored only while untouched). + * Default sink: submit through the real host session and report acceptance + * to the input transaction. The draft and its reference occurrences remain + * resident until this promise succeeds. */ - private sink(session: Session, text: string, mode: 'queue' | 'steer'): void { - if (text === '') return - const shell = this.shells.get(session.sessionId) - // Commit, not an editable clear: undo must not resurrect sent content. - shell?.commitSend() - void session.prompt([{ type: 'text', text }], mode).then( - (result) => { - if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text) - }, - () => { - if (shell?.snapshot.draft === '') shell.setDraft(text) - }, - ) + private async sink( + session: Session, + text: string, + mode: 'queue' | 'steer', + signal: AbortSignal, + ): Promise { + if (text === '') return { kind: 'error', text: 'prompt is empty' } + const result = await session.prompt([{ type: 'text', text }], mode, signal) + return result.ok + ? { kind: 'success' } + : { kind: 'error', text: result.error.message } } private controller(actx: ClientContext): SlashController | undefined { diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index f9c5a479a4..21d5f3b019 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -167,7 +167,6 @@ export class InputMachine { case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) - case 'send-committed': return this.onSendCommitted() case 'release': return this.onRelease() default: return unreachable(ev) } @@ -477,7 +476,9 @@ export class InputMachine { this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - return [{ type: 'default-sink', draft: this.draft, mode }] + const attempt = this.beginAttempt(mode) + this.phase = 'submitting' + return [{ type: 'default-sink', attempt, draft: this.draft, mode }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -495,11 +496,18 @@ export class InputMachine { } // 'handled' (source dealt internally), {insert} (no enter-time span // semantics), or a miss: all land plain; only the miss flows to the sink. + if (outcome === undefined) { + this.phase = 'submitting' + return [{ + type: 'default-sink', + attempt, + draft: attempt.draftSnapshot, + mode: flight.mode, + }] + } this.inflight = undefined this.phase = 'plain' - return outcome === undefined - ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }] - : [] + return [] } private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] { @@ -529,8 +537,8 @@ export class InputMachine { : [] } const text = ev.message ?? ev.outcome?.text ?? 'command failed' - // Drift guard: keep the enter-time draft (same claim) only while the - // live draft still equals it; user input typed during flight wins. + // Keep the same command claim only while the live draft still equals the + // enter-time draft; user input typed during flight wins. // Claimed re-entry additionally requires the watch to hold — an // enter-path snapshot may carry leading whitespace the token never had. if (this.draft === flight.attempt.draftSnapshot @@ -543,19 +551,6 @@ export class InputMachine { return [{ type: 'notice', level: 'error', text }] } - /** Ordinary send accepted: clear as a commit (no undo unit; sent content - * must not be resurrectable — same discipline as submit-settled success). */ - private onSendCommitted(): InputEffect[] { - this.claim = undefined - this.occurrences = [] - this.adopt('') - this.log = [] - this.redoStack = [] - this.typingRun = undefined - this.paste = undefined - return [] - } - private onRelease(): InputEffect[] { if (this.inflight !== undefined) { this.inflight.controller.abort() diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index da255415ce..37033179d3 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom // apply inject factories exercised end to end against the terminal thin // shape: the conversation surface (views triple, send choreography incl. -// optimistic clear + failure restore THROUGH the declared store actions, +// accepted-settlement clear + failure retention THROUGH the declared store actions, // openDetails = select action + layout orchestration, sessions.open // navigation), and the closeDetails details surface. Complements // chat-apply.spec.tsx (registration) @@ -185,7 +185,7 @@ describe('conversation slot inject surface', () => { expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) }) - it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => { + it('the provide-channel input face submits through the machine sink: trim, accepted clear, failure retention without clobber', async () => { const b = await bench() const { injected } = b.conversationSurface(ROOT) const { state, actions } = b.inputSurface(ROOT) @@ -194,20 +194,27 @@ describe('conversation slot inject surface', () => { actions.submit('queue') expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(state.getSnapshot().draft).toBe(' ') - // Success: cleared and stays cleared. + // Success: retained while the host decides, then cleared on acceptance. actions.setDraft('hello') actions.submit('queue') - expect(state.getSnapshot().draft).toBe('') - await Promise.resolve() - expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') - // Failure: restored (draft still empty when the rejection lands). + expect(state.getSnapshot().draft).toBe('hello') + await vi.waitFor(() => { + expect(state.getSnapshot().draft).toBe('') + }) + expect(b.sessionFake.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'hello' }], + 'queue', + expect.any(AbortSignal), + ) + // Failure: the original draft remains available for retry. b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) actions.setDraft('retry me') actions.submit('queue') + expect(state.getSnapshot().draft).toBe('retry me') await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('retry me') }) - // Failure landing after new typing: no clobber (restore fills empty only). + // Failure landing after new typing: no clobber. b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) actions.submit('queue') actions.setDraft('typed during flight') diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index be50356185..a48653a132 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -18,6 +18,52 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx afterEach(cleanup) describe('MessageItem arms', () => { + it('shows referenced-session labels below the direct user prompt', () => { + const view = render( + , + ) + expect(view.container.textContent).toContain('compare @Research notes') + expect(view.container.querySelector('[data-ref-chip="reference"]')?.textContent).toBe('@Research notes') + expect(view.getByText('引用会话 · Research notes, fallback')).toBeTruthy() + }) + + it('styles a referenced-session label when prompt text follows without whitespace', () => { + const view = render( + , + ) + expect(view.container.textContent).toContain('@你好这个在讲啥') + expect(view.container.querySelector('[data-ref-chip="reference"]')?.textContent).toBe('@你好') + }) + it('steering bubbles carry the interjection badge and non-text rest blocks', () => { const view = render( Promise.resolve({ kind: 'success' as const })) const lex = over?.lexicon type ShellDeps = ConstructorParameters[0] const shell = new SessionInputShell({ @@ -109,7 +109,7 @@ describe('Enter semantics', () => { it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('hello', 'queue') + expect(sink).toHaveBeenCalledWith('hello', 'queue', expect.any(AbortSignal)) fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) @@ -177,7 +177,7 @@ describe('running and lock semantics (queue cut 1)', () => { expect(textarea.disabled).toBe(false) // running no longer locks fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', 'queue') + expect(sink).toHaveBeenCalledWith('排队消息2', 'queue', expect.any(AbortSignal)) expect(button.getAttribute('aria-label')).toBe('Stop generating') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) @@ -193,7 +193,7 @@ describe('running and lock semantics (queue cut 1)', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go', 'queue') + expect(sink).toHaveBeenCalledWith('go', 'queue', expect.any(AbortSignal)) const empty = bench() expect(empty.button.disabled).toBe(true) }) diff --git a/packages/client/ui-conversation/tests/input-machine.spec.ts b/packages/client/ui-conversation/tests/input-machine.spec.ts index 206a66e4c6..b6080d208d 100644 --- a/packages/client/ui-conversation/tests/input-machine.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.spec.ts @@ -72,9 +72,10 @@ describe('input-machine: plain × enter', () => { it('non-command text falls to the default sink with the given mode', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - expect(m.dispatch({ type: 'enter', mode: 'steer' })) - .toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }]) - expect(m.state.phase).toBe('plain') + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'steer' }), 0, 'default-sink') + expect(effect).toMatchObject({ draft: 'hello world', mode: 'steer' }) + expect(effect.attempt.draftSnapshot).toBe('hello world') + expect(m.state.phase).toBe('submitting') }) it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => { @@ -97,8 +98,8 @@ describe('input-machine: plain × enter', () => { it('a non-whitespace prefix before "/" is not leading — default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' }) - expect(m.dispatch({ type: 'enter', mode: 'queue' })) - .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }]) + expect(effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink')) + .toMatchObject({ draft: '第一行\n/goal x', mode: 'queue' }) }) }) @@ -127,9 +128,12 @@ describe('input-machine: adjudication outcomes', () => { it('undefined outcome falls back to the default sink preserving the enter mode', () => { const m = new InputMachine() const attempt = enterAdjudicating(m, '/unknown thing', 'steer') - expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) - .toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }]) - expect(m.state.phase).toBe('plain') + expect(effectAt( + m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }), + 0, + 'default-sink', + )).toMatchObject({ attempt, draft: '/unknown thing', mode: 'steer' }) + expect(m.state.phase).toBe('submitting') }) it("'handled' lands plain with zero effects (popup shell path)", () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 6b60f7ea17..eefacee1f5 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -50,7 +50,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled } function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise }) { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink }) const wiring = shell const view = mountBar(shell, over) @@ -71,13 +71,16 @@ function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: s } describe('matrix row: plain', () => { - it('enter falls to the default sink; no claim on the currency; edits free', () => { + it('enter falls to the default sink; no claim on the currency; edits free', async () => { const { textarea, shell, sink } = bench() fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息', 'queue') - expect(shell.snapshot.phase).toBe('plain') + expect(sink).toHaveBeenCalledWith('普通消息', 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.phase).toBe('submitting') + await vi.waitFor(() => { + expect(shell.snapshot.phase).toBe('plain') + }) }) }) @@ -175,7 +178,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea as HTMLTextAreaElement).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队', 'queue') + expect(sink).toHaveBeenCalledWith('排队', 'queue', expect.any(AbortSignal)) }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.spec.ts new file mode 100644 index 0000000000..1fed5ef9ed --- /dev/null +++ b/packages/client/ui-conversation/tests/input-reference-submit.spec.ts @@ -0,0 +1,116 @@ +/** + * Reference-submit transaction coverage: chips serialize through their + * owner, stay resident through Host rejection, and clear only after an + * accepted prompt. + */ +import { describe, expect, it, vi } from 'vitest' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { SlashController, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' +import { SessionInputShell } from '../src/client/input/facade.ts' +import { PLACEHOLDER } from '../src/client/input/machine.ts' + +const mention = '@[Research](dsh-session:InNvdXJjZSI)' + +function chip(shell: SessionInputShell): void { + shell.setDraft('@res') + const accepted = shell.insertReference({ + source: 'reference', + ref: mention, + label: '@Research', + clipboardText: mention, + }, { + start: 0, + end: 4, + draftRev: shell.snapshot.draftRev, + }) + expect(accepted).toBe(true) +} + +describe('reference submission', () => { + it('retains the chip on Host failure and clears it only after a later accepted retry', async () => { + const serializeReference = vi.fn(() => Promise.resolve(mention)) + const sink = vi.fn<(_text: string, _mode: 'queue' | 'steer') => Promise>() + .mockResolvedValueOnce({ kind: 'error', text: 'snapshot unavailable' }) + .mockResolvedValueOnce({ kind: 'success' }) + const slash = { + serializeReference, + track: vi.fn(), + } as unknown as SlashController + const shell = new SessionInputShell({ + actx: {} as ClientContext, + slash: () => slash, + defaultSink: sink, + }) + chip(shell) + expect(shell.snapshot).toMatchObject({ + draft: PLACEHOLDER, + occurrences: [{ source: 'reference', ref: mention, label: '@Research' }], + }) + + shell.submit('queue') + expect(shell.snapshot.phase).toBe('submitting') + await vi.waitFor(() => { + expect(shell.snapshot.phase).toBe('plain') + }) + expect(sink).toHaveBeenNthCalledWith(1, mention, 'queue', expect.any(AbortSignal)) + expect(shell.snapshot).toMatchObject({ + draft: PLACEHOLDER, + occurrences: [{ source: 'reference', ref: mention, label: '@Research' }], + }) + expect(shell.notices.getSnapshot()).toMatchObject({ + level: 'error', + text: 'snapshot unavailable', + }) + + shell.submit('queue') + await vi.waitFor(() => { + expect(shell.snapshot.draft).toBe('') + }) + expect(sink).toHaveBeenNthCalledWith(2, mention, 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.occurrences).toEqual([]) + expect(serializeReference).toHaveBeenCalledTimes(2) + }) + + it('blocks submission and retains the chip when its owner cannot serialize it', async () => { + const sink = vi.fn() + const slash = { + serializeReference: () => Promise.reject(new Error('reference codec unavailable')), + track: vi.fn(), + } as unknown as SlashController + const shell = new SessionInputShell({ + actx: {} as ClientContext, + slash: () => slash, + defaultSink: sink, + }) + chip(shell) + shell.submit() + await vi.waitFor(() => { + expect(shell.snapshot.phase).toBe('plain') + }) + expect(sink).not.toHaveBeenCalled() + expect(shell.snapshot.draft).toBe(PLACEHOLDER) + expect(shell.snapshot.occurrences).toHaveLength(1) + expect(shell.notices.getSnapshot()).toMatchObject({ + level: 'error', + text: 'reference codec unavailable', + }) + }) + + it('aborts Host-side preparation when the input shell is disposed', () => { + let signal: AbortSignal | undefined + const shell = new SessionInputShell({ + actx: {} as ClientContext, + defaultSink: (_text, _mode, received) => { + signal = received + return new Promise(() => {}) + }, + }) + shell.setDraft('send this') + shell.submit() + expect(signal?.aborted).toBe(false) + shell.dispose() + expect(signal?.aborted).toBe(true) + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('send this') + }) +}) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 9a99eccbf0..4ba6926094 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -102,7 +102,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { register?.(slash) const actx = sessions.scope(sessionId)! as ClientContext const controller = slash.sessionOf(actx) - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink }) // The hub's listener wiring, verbatim. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined) @@ -215,7 +215,9 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') }) + await vi.waitFor(() => { + expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue', expect.any(AbortSignal)) + }) expect(b2.executed).toHaveLength(0) }) }) @@ -240,7 +242,9 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') }) + await vi.waitFor(() => { + expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue', expect.any(AbortSignal)) + }) expect(b.shell.snapshot.phase).toBe('plain') expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 623ee93202..64792fa8e0 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -21,7 +21,7 @@ import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts' /** Machine-backed wiring over a sink spy. */ function fakeWiring() { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink }) return { wiring: shell, sink, shell } } @@ -152,7 +152,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue') + expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue', expect.any(AbortSignal)) fireEvent.click(b.view.getByRole('button', { name: 'Root' })) expect(b.open).toHaveBeenCalledWith(sid('root')) }) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml similarity index 55% rename from packages/client/ui-subagent/README.i18n.yaml rename to packages/client/ui-reference/README.i18n.yaml index 86995fc65c..9cfc4baf07 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-reference/README.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 -README.md: 7a70add139eae7bc507469b4fe7170359efdec31 -README.zh.md: 2d8ee677c71179df88211d90120a6017ceac8f6a +# pnpm run verify-translation-pairing --write packages/client/ui-reference/README.md +README.md: e7b280c09cf33f9c0c38ebffe0c5e4a9d22062fc +README.zh.md: 4d9d41d24a9a4a61859a7fcf8d19cdb8742c475e diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md new file mode 100644 index 0000000000..e7b280c09c --- /dev/null +++ b/packages/client/ui-reference/README.md @@ -0,0 +1,25 @@ +# `@deepseek-ai/dsh-client-ui-reference` + +English | [中文](README.zh.md) + +Unified Web `@file` and `@session` source. The browser starts `reference.files` and `reference.sessions` Host RPCs together for an unquoted token, keeps the TUI's file-before-session ordering and labels, renders the rows under the non-selectable `文件与文件夹` and `Session 对话` headings, and degrades either failed candidate domain independently. An open `@"…` token searches files only. + +File picks insert the natural `@path` text used by the TUI. A file closes completion and adds a trailing space; a directory keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted. + +Session picks insert an atomic composer chip whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. The visible chip uses `@label`; serialization never reconstructs identity from that label. Ordinary send delegates the canonical mention to `session.prompt`, where Host-side session-reference preparation owns validation, snapshotting, and model context. + +The `/client` export is the plugin body (`apply`/`inject`) only; candidate encoding stays internal to the registration effect. + +## Model Experience + +Indirectly, through `@deepseek-ai/dsh-file-reference-local` for path guidance and `@deepseek-ai/dsh-session-reference` for prepared session snapshots. + +#### KV Cache effect + +Candidate browsing has no model effect. A selected file or session changes only the new user-message suffix and any Host-prepared session-reference prefix attached to that message; earlier target history remains unchanged. + +## Known Limitations and Deferred Work + +- **Candidate failure is intentionally quiet** — one unavailable or failed reference RPC yields no rows for that domain, while prompt submission still reports session-reference preparation failures through the ordinary send path. +- **No browser-side file scan** — Web completion requires a mounted Host `ctx.fileReferences` provider; the browser cannot fall back to its own filesystem. +- **Session search remains metadata-only** — discovery filters session id and cwd through `ctx.sessionReferences`; title and transcript full-text search are not available. diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md new file mode 100644 index 0000000000..4d9d41d24a --- /dev/null +++ b/packages/client/ui-reference/README.zh.md @@ -0,0 +1,25 @@ +# `@deepseek-ai/dsh-client-ui-reference` + +[English](README.md) | 中文 + +统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `reference.files` 和 `reference.sessions` 宿主 RPC,沿用 TUI 中文件在会话之前的顺序和标签,把各行分别渲染在不可选择的 `文件与文件夹` 和 `Session 对话` 标题下,并让任一候选领域的失败独立降级。尚未闭合的 `@"…` token 只搜索文件。 + +选择文件会插入 TUI 使用的自然 `@path` 文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。 + +选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` 提及标记。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会把规范提及标记交给 `session.prompt`,由宿主侧的会话引用准备负责校验、生成快照和模型上下文。 + +`/client` 只导出插件主体(`apply`/`inject`);候选编码保留在注册 effect 内部。 + +## 模型体验 + +间接影响模型体验:路径指引由 `@deepseek-ai/dsh-file-reference-local` 提供,准备后的会话快照由 `@deepseek-ai/dsh-session-reference` 提供。 + +#### KV 缓存影响 + +浏览候选项不会影响模型。选择文件或会话只会改变新用户消息的后缀,以及附加到该消息、由宿主准备的会话引用前缀;目标会话更早的历史保持不变。 + +## 已知限制与暂缓事项 + +- **候选失败有意保持静默**:引用 RPC 不可用或失败时,该领域不产生候选行;提示词提交仍会通过普通发送路径报告会话引用准备失败。 +- **浏览器侧不扫描文件**:Web 补全需要挂载宿主 `ctx.fileReferences` 提供方;浏览器无法回退到自身文件系统。 +- **会话搜索仍仅使用元数据**:发现流程通过 `ctx.sessionReferences` 筛选 session id 和 cwd;无法对标题和 transcript(文本记录)进行全文搜索。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-reference/package.json similarity index 78% rename from packages/client/ui-subagent/package.json rename to packages/client/ui-reference/package.json index 9ff379b676..03825e0cd3 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-reference/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-client-ui-subagent", - "description": "Subagent reference source: '@' menu candidates from the session snapshot (zero RPC), inserts @label references", + "name": "@deepseek-ai/dsh-client-ui-reference", + "description": "Unified Web @file and @session reference source", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-slash" ], @@ -35,17 +36,19 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-file-reference": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "cordis": "^4.0.0-rc.7" }, "files": [ diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts new file mode 100644 index 0000000000..157498f820 --- /dev/null +++ b/packages/client/ui-reference/src/client/index.ts @@ -0,0 +1,115 @@ +/** + * Unified Web `@` reference source. File and session discovery run through + * cancellable Host RPCs in parallel and retain the TUI's ordering and labels. + * + * @module @deepseek-ai/dsh-client-ui-reference/client + */ +import type { ConnectionHandle, FileReferenceItem, SessionReferenceItem } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' +import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' + +const FILE_SECTION = '文件与文件夹' +const SESSION_SECTION = 'Session 对话' + +/** Required services: the slash registry and Host connection. */ +export const inject = ['slash', 'connection'] + +/** + * Register the combined `@file` / `@session` source. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + const references = (ctx.get('connection') as ConnectionHandle).api.references + const source: SlashSource = { + trigger: '@', + name: 'reference', + async candidates(session: ClientSessionContext, { query, quoted, signal }) { + const files = references.files({ sessionId: session.sessionId, query }, signal).then( + response => response.result.ok ? response.result.value.items : [], + () => [], + ) + const sessions = quoted === true + ? Promise.resolve([] as SessionReferenceItem[]) + : references.sessions({ sessionId: session.sessionId, query }, signal).then( + response => response.result.ok ? response.result.value.items : [], + () => [], + ) + const [fileItems, sessionItems] = await Promise.all([files, sessions]) + if (signal.aborted) return [] + return [ + ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true)), + ...sessionItems.map(sessionCandidate), + ] + }, + onPick({ candidate }) { + const value = parseCandidate(candidate.value) + if (value?.kind === 'file') { + return { + text: value.mention + (value.fileKind === 'file' ? ' ' : ''), + ...value.fileKind === 'directory' ? { continue: true } : {}, + } + } + if (value?.kind === 'session') { + return { + insert: { + source: 'reference', + ref: value.mention, + label: `@${value.label}`, + clipboardText: value.mention, + }, + } + } + return undefined + }, + codec: { + clipboardText: ref => ref, + serialize: ref => Promise.resolve(ref), + }, + } + const slash = ctx.get('slash') as SlashServiceContract + ctx.effect(() => slash.registerSource(source), 'ui-reference: @ source') +} + +type ReferenceCandidateValue = + | { kind: 'file'; fileKind: FileReferenceItem['kind']; mention: string } + | { kind: 'session'; label: string; mention: string } + +function fileCandidate(candidate: FileReferenceItem, preserveQuote: boolean) { + const mention = formatFileMention(candidate, preserveQuote) + if (mention === undefined) return [] + const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) + const directory = candidate.kind === 'directory' + const value: ReferenceCandidateValue = { + kind: 'file', + fileKind: candidate.kind, + mention, + } + return [{ + name: `${directory ? 'Folder' : 'File'} · ${name}${directory ? '/' : ''}`, + description: candidate.path, + section: FILE_SECTION, + value: JSON.stringify(value), + }] +} + +function sessionCandidate(candidate: SessionReferenceItem) { + const location = candidate.cwd ?? '(no cwd)' + const description = `${candidate.label === candidate.sessionId ? '' : `${candidate.sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` + const value: ReferenceCandidateValue = { + kind: 'session', + label: candidate.label, + mention: candidate.mention, + } + return { + name: `Session · ${candidate.label}`, + description, + section: SESSION_SECTION, + value: JSON.stringify(value), + } +} + +function parseCandidate(value: string | undefined): ReferenceCandidateValue | undefined { + if (value === undefined) return undefined + return JSON.parse(value) as ReferenceCandidateValue +} diff --git a/packages/client/ui-subagent/src/index.ts b/packages/client/ui-reference/src/index.ts similarity index 79% rename from packages/client/ui-subagent/src/index.ts rename to packages/client/ui-reference/src/index.ts index 825b860701..b8c3f990f8 100644 --- a/packages/client/ui-subagent/src/index.ts +++ b/packages/client/ui-reference/src/index.ts @@ -1,5 +1,5 @@ /** - * Subagent reference plugin, node half. Pure UI plugin: the empty apply + * File/session reference 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. diff --git a/packages/client/ui-subagent/src/invariant.ts b/packages/client/ui-reference/src/invariant.ts similarity index 83% rename from packages/client/ui-subagent/src/invariant.ts rename to packages/client/ui-reference/src/invariant.ts index 645f88c9b6..8f4c694cea 100644 --- a/packages/client/ui-subagent/src/invariant.ts +++ b/packages/client/ui-reference/src/invariant.ts @@ -1,16 +1,16 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-subagent`. - * @module @deepseek-ai/dsh-client-ui-subagent/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-reference`. + * @module @deepseek-ai/dsh-client-ui-reference/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-subagent' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-reference' /** Cordis companion plugin name. */ -export const name = 'client-ui-subagent-invariant' +export const name = 'client-ui-reference-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] diff --git a/packages/client/ui-reference/tests/browser-plugin.spec.ts b/packages/client/ui-reference/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..ca70d56dcc --- /dev/null +++ b/packages/client/ui-reference/tests/browser-plugin.spec.ts @@ -0,0 +1,325 @@ +/** + * Web reference source coverage: Host-backed file/session discovery, TUI + * ordering and labels, quoted-path suppression, pick projections, codec + * round-trip, and registration lifecycle. + */ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import type { FileReferenceItem, SessionReferenceItem } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { + CandidateRequest, ClientSessionContext, SlashCandidate, SlashSource, +} from '@deepseek-ai/dsh-client-ui-slash/client' +import { apply, inject } from '../src/client/index.ts' + +const sid = (value: string): SessionId => value as SessionId +const session: ClientSessionContext = { sessionId: sid('target') } + +type ReferenceResponse = + | { result: { ok: true; value: { items: T[] } } } + | { result: { ok: false; error: { code: string; message: string } } } + +type ReferenceLookup = ( + payload: unknown, + signal?: AbortSignal, +) => Promise> + +function request( + query: string, + options: { quoted?: boolean; signal?: AbortSignal } = {}, +): CandidateRequest { + return { + query, + position: 'inline', + signal: options.signal ?? new AbortController().signal, + ...options.quoted === undefined ? {} : { quoted: options.quoted }, + } +} + +async function bench( + files: ReferenceLookup = vi.fn(() => Promise.resolve({ + result: { + ok: true as const, + value: { + items: [ + { path: 'src', kind: 'directory' as const }, + { path: 'docs/a b.md', kind: 'file' as const }, + ], + }, + }, + })), + sessions: ReferenceLookup = vi.fn(() => Promise.resolve({ + result: { + ok: true as const, + value: { + items: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 1_700_000_000_000, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + }, + }, + })), +): Promise<{ ctx: Context; fiber: ReturnType; source: SlashSource }> { + const ctx = new Context() + let source: SlashSource | undefined + ctx.provide('slash', { + registerSource(candidate: SlashSource) { + source = candidate + return () => { source = undefined } + }, + }) + ctx.provide('connection', { api: { references: { files, sessions } } } as never) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + if (source === undefined) throw new Error('reference source was not registered') + return { ctx, fiber, source } +} + +describe('apply', () => { + it('declares its services and releases the @ reference registration on disposal', async () => { + expect(inject).toEqual(['slash', 'connection']) + const ctx = new Context() + let registered: SlashSource | undefined + ctx.provide('slash', { + registerSource(source: SlashSource) { + registered = source + return () => { registered = undefined } + }, + }) + ctx.provide('connection', { + api: { + references: { + files: () => Promise.resolve({ result: { ok: true, value: { items: [] } } }), + sessions: () => Promise.resolve({ result: { ok: true, value: { items: [] } } }), + }, + }, + } as never) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(registered).toMatchObject({ trigger: '@', name: 'reference' }) + await fiber.dispose() + expect(registered).toBeUndefined() + }) +}) + +describe('candidates', () => { + it('starts both Host lookups together and renders files before sessions with TUI labels', async () => { + let releaseFiles!: () => void + let releaseSessions!: () => void + const files = vi.fn(() => new Promise<{ + result: { ok: true; value: { items: { path: string; kind: 'file' | 'directory' }[] } } + }>((resolve) => { + releaseFiles = () => { + resolve({ + result: { + ok: true, + value: { + items: [ + { path: 'src', kind: 'directory' }, + { path: 'docs/a b.md', kind: 'file' }, + ], + }, + }, + }) + } + })) + const sessions = vi.fn(() => new Promise<{ + result: { + ok: true + value: { + items: { + sessionId: SessionId + label: string + cwd: string + createdAt: number + mention: string + }[] + } + } + }>((resolve) => { + releaseSessions = () => { + resolve({ + result: { + ok: true, + value: { + items: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 1_700_000_000_000, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + }, + }, + }) + } + })) + const { source } = await bench(files, sessions) + const pending = source.candidates(session, request('re')) + expect(files).toHaveBeenCalledTimes(1) + expect(sessions).toHaveBeenCalledTimes(1) + releaseSessions() + releaseFiles() + await expect(pending).resolves.toEqual([ + expect.objectContaining({ + name: 'Folder · src/', + description: 'src', + section: '文件与文件夹', + }), + expect.objectContaining({ + name: 'File · a b.md', + description: 'docs/a b.md', + section: '文件与文件夹', + }), + expect.objectContaining({ + name: 'Session · Research', + description: 'source · /project · 2023-11-14T22:13:20.000Z', + section: 'Session 对话', + }), + ]) + }) + + it('suppresses sessions for an open quoted path and degrades each failed domain independently', async () => { + const files = vi.fn() + .mockResolvedValueOnce({ + result: { + ok: true as const, + value: { items: [{ path: 'README.md', kind: 'file' as const }] }, + }, + }) + .mockRejectedValueOnce(new Error('file scan failed')) + const sessions = vi.fn(() => Promise.resolve({ + result: { + ok: true as const, + value: { + items: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 0, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + }, + }, + })) + const { source } = await bench(files, sessions) + const quoted = await source.candidates(session, request('READ', { quoted: true })) + expect(quoted).toEqual([expect.objectContaining({ name: 'File · README.md' })]) + expect(source.onPick({ + candidate: quoted[0]!, + session, + position: 'inline', + via: 'menu', + span: { start: 0, end: 6, draftRev: 1 }, + })).toEqual({ text: '@"README.md" ' }) + expect(sessions).not.toHaveBeenCalled() + await expect(source.candidates(session, request('research'))).resolves.toEqual([ + expect.objectContaining({ name: 'Session · Research' }), + ]) + }) + + it('drops a completed result when the query signal was superseded', async () => { + const controller = new AbortController() + const { source } = await bench() + const pending = source.candidates(session, request('', { signal: controller.signal })) + controller.abort() + await expect(pending).resolves.toEqual([]) + }) + + it('treats Host errors as empty domains and filters paths that cannot be mentioned', async () => { + const files = vi.fn(() => Promise.resolve({ + result: { + ok: true as const, + value: { items: [{ path: 'bad\nname', kind: 'file' as const }] }, + }, + })) + const sessions = vi.fn() + .mockRejectedValueOnce(new Error('session lookup failed')) + .mockResolvedValueOnce({ + result: { + ok: false as const, + error: { code: 'reference-failed', message: 'session lookup failed' }, + }, + }) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) + + files.mockResolvedValueOnce({ + result: { + ok: false as const, + error: { code: 'reference-failed', message: 'file lookup failed' }, + }, + } as never) + await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) + }) + + it('omits redundant session ids and labels sessions without a cwd', async () => { + const files = vi.fn(() => Promise.resolve({ + result: { ok: true as const, value: { items: [] } }, + })) + const sessions = vi.fn(() => Promise.resolve({ + result: { + ok: true as const, + value: { + items: [{ + sessionId: sid('same'), + label: 'same', + createdAt: 0, + mention: '@[same](dsh-session:InNhbWUi)', + }], + }, + }, + })) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, request('same'))).resolves.toEqual([ + expect.objectContaining({ + name: 'Session · same', + description: '(no cwd) · 1970-01-01T00:00:00.000Z', + }), + ]) + }) +}) + +describe('pick and codec', () => { + const pick = (source: SlashSource, candidate: SlashCandidate) => source.onPick({ + candidate, + session, + position: 'inline', + via: 'menu', + span: { start: 0, end: 1, draftRev: 1 }, + }) + + it('inserts files as path text, keeping directory completion open', async () => { + const { source } = await bench() + const [directory, file] = await source.candidates(session, request('')) + expect(pick(source, directory!)).toEqual({ text: '@src/', continue: true }) + expect(pick(source, file!)).toEqual({ text: '@"docs/a b.md" ' }) + const [quotedDirectory] = await source.candidates(session, request('', { quoted: true })) + expect(pick(source, quotedDirectory!)).toEqual({ text: '@"src/', continue: true }) + }) + + it('inserts sessions as atomic chips whose clipboard and model forms are canonical mentions', async () => { + const { source } = await bench() + const candidates = await source.candidates(session, request('')) + const candidate = candidates.find(item => item.name === 'Session · Research')! + const mention = '@[Research](dsh-session:InNvdXJjZSI)' + expect(pick(source, candidate)).toEqual({ + insert: { + source: 'reference', + ref: mention, + label: '@Research', + clipboardText: mention, + }, + }) + expect(source.codec?.clipboardText(mention)).toBe(mention) + await expect(source.codec?.serialize(mention, new AbortController().signal)).resolves.toBe(mention) + }) + + it('ignores candidates that do not carry a source-owned value', async () => { + const { source } = await bench() + expect(pick(source, { name: 'foreign candidate' })).toBeUndefined() + }) +}) diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-reference/tsconfig.json similarity index 81% rename from packages/client/ui-subagent/tsconfig.json rename to packages/client/ui-reference/tsconfig.json index b33f801293..3662140f2a 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection" + }, { "path": "../runtime" }, @@ -18,7 +21,7 @@ "path": "../ui-slash" }, { - "path": "../ui-slots" + "path": "../../context/file-reference" }, { "path": "../../support/invariants" diff --git a/packages/client/ui-reference/tsdown.config.ts b/packages/client/ui-reference/tsdown.config.ts new file mode 100644 index 0000000000..1c70dc948a --- /dev/null +++ b/packages/client/ui-reference/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-reference', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index c09d7f4c28..331181646d 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/README.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 -README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a -README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018 +# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md +README.md: 45bdff01605b51f18e08732930e8b35fec81f872 +README.zh.md: 6af98eb81edc66f5bf446f1b78e4b3dc6f0d1ce8 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index d2978695d7..45bdff0160 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -2,11 +2,13 @@ English | [中文](README.zh.md) -Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. +Input trigger pipeline plugin: `/` and `@` detection under the caret, the grouped candidate menu, and pick routing to registered sources. Slash detection keeps its word-boundary and guard-tier rules; `@` uses the shared TUI grammar and opens only at input start or after whitespace, including an unfinished `@"path with spaces` token. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration. -MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. +MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. A candidate's optional `section` renders one non-selectable heading for each contiguous section without entering the keyboard-selection index. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`. + +Pick outcomes may insert plain text, an atomic reference chip, or request continued completion. Continued text picks replace the active token, then immediately retrack the resulting draft; `@file` directories use this path to keep completion open below the selected directory. The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it. @@ -23,4 +25,4 @@ None; this package neither assembles nor sends a provider request. - **Global source layer only** — session-scope source registration (per-session shadowing, ScopedLayers-alike) is designed but not enabled; the ledger tracks the trigger condition (a real per-session source need). - **`SlashCandidate.icon` renders as text** — MenuView drops the string into the icon slot verbatim; wiring to the design-system icon enum (iconFile five-variant family) lands when that enum ships. - **Overlay SlotMap merge home is split from slot ownership** — the `conversation.input.overlay` merge lives here (sole copy) while the slot's owner semantics (anchor, children declaration, lifecycle) stay with ui-conversation; the dependency direction (ui-conversation → ui-slash) forces the split, so a future dependency reshuffle should revisit it. -- **Menu group order is registration order** — no explicit ordering seam across sources; acceptable while the roster is command/skill/subagent, revisit if business sources join. +- **Menu group order is registration order** — no explicit ordering seam across sources; acceptable while the roster is command/skill/reference, revisit if more business sources join. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 6aeb078a92..6af98eb81e 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -2,11 +2,13 @@ [English](README.md) | 中文 -输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 +输入触发流水线插件:光标处的 `/` 与 `@` 检测、分组候选菜单,以及把 pick 路由到已注册 source。斜杠命令检测沿用其词边界与 guard tier 规则;`@` 使用 TUI 的共享语法,只会在输入开头或空白后打开,也能识别尚未闭合的 `@"path with spaces` token。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。流水线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。 -MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。该 slot 由 ui-conversation 的编辑器配置项拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 +MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类,会话 scope),菜单关闭期间渲染 null。候选项的可选 `section` 字段会为每段连续分组渲染一个不可选择的标题,且不会进入键盘选择索引。该 slot 由 ui-conversation 的编辑器配置项拥有(锚点、children 声明、生命周期);其 SlotMap 类型合并放在本包的 `src/client/slots.ts`,因为依赖方向(ui-conversation → ui-slash)不允许反向的类型导入。combobox 模式:焦点始终留在 textarea,行在 mousedown 时完成 pick,高亮由 `aria-activedescendant` 承载。 + +pick 结果可以插入普通文本、原子引用 chip,或者请求继续补全。需要继续补全的文本 pick 会替换活跃 token,随后立即根据新草稿重新跟踪;`@file` 目录通过此路径让补全在所选目录下保持打开。 `/client` 导出表层是插件主体(`apply`/`inject`)、`SlashService`、`MenuViewInjected` 与契约类型。MenuView 本身是内部实现——slot 注册以闭包持有它。 @@ -23,4 +25,4 @@ MenuView 把菜单 store 渲染进 `conversation.input.overlay` slot(列表类 - **只有全局 source 层**:会话 scope 的 source 注册(逐会话遮蔽、类 ScopedLayers 机制)已有设计但未启用;台账记录着触发条件(出现真实的逐会话 source 需求)。 - **`SlashCandidate.icon` 以文本渲染**:MenuView 把该字符串原样放进图标位;接到设计系统图标枚举(iconFile 五变体家族)的接线等该枚举交付后落地。 - **overlay 的 SlotMap 合并归属与 slot 所有权分离**:`conversation.input.overlay` 的合并放在本包(唯一副本),而该 slot 的 owner 语义(锚点、children 声明、生命周期)留在 ui-conversation;依赖方向(ui-conversation → ui-slash)迫使这一拆分,未来依赖关系调整时应重新审视。 -- **菜单组顺序即注册顺序**:source 之间没有显式排序 seam;roster 还是 command/skill/subagent 时可以接受,业务 source 加入后需重新审视。 +- **菜单组顺序即注册顺序**:source 之间没有显式排序 seam;roster 还是 command/skill/reference 时可以接受,更多业务 source 加入后需重新审视。 diff --git a/packages/client/ui-slash/package.json b/packages/client/ui-slash/package.json index 1c376c5492..5d60dae847 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -37,6 +37,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-file-reference": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -44,6 +45,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/ui-slash/src/client/MenuView.module.css b/packages/client/ui-slash/src/client/MenuView.module.css index bb41e949d0..1f9128c199 100644 --- a/packages/client/ui-slash/src/client/MenuView.module.css +++ b/packages/client/ui-slash/src/client/MenuView.module.css @@ -44,6 +44,20 @@ background: var(--dsw-alias-interactive-bg-hover); } +.sectionTitle { + flex: none; + min-height: 26px; + padding: 6px 10px 2px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + font-weight: 500; + line-height: 18px; +} + +.sectionTitle:not(:first-child) { + margin-top: 4px; +} + .itemIcon { display: inline-flex; flex: none; diff --git a/packages/client/ui-slash/src/client/MenuView.tsx b/packages/client/ui-slash/src/client/MenuView.tsx index a6336e71e1..e564b0bdc0 100644 --- a/packages/client/ui-slash/src/client/MenuView.tsx +++ b/packages/client/ui-slash/src/client/MenuView.tsx @@ -6,7 +6,7 @@ * pattern — focus never leaves the textarea, so rows are mousedown-handled * and the highlight is exposed via aria-activedescendant on the listbox). */ -import { useSyncExternalStore } from 'react' +import { Fragment, useSyncExternalStore } from 'react' import clsx from 'clsx' import css from './MenuView.module.css' import type { MenuViewInjected } from './slots.ts' @@ -40,25 +40,29 @@ export function MenuView({ menu, onPick }: MenuViewInjected) { : group.items.map((item, index) => { const active = highlight !== null && highlight.source === group.source && highlight.index === index return ( - + + {item.section !== undefined && item.section !== group.items[index - 1]?.section + ?
{item.section}
+ : null} + +
) }))}
diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index d3d3567e4d..e54255937f 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -75,6 +75,7 @@ export class SlashController { const prev = this.menu.getSnapshot() const same = prev.open && prev.hit !== null && prev.hit.trigger === hit.trigger && prev.hit.query === hit.query + && prev.hit.quoted === hit.quoted && prev.hit.span.start === hit.span.start && prev.hit.span.end === hit.span.end this.hit = hit if (same) return @@ -243,7 +244,11 @@ export class SlashController { return actx.bail(actx, 'slash/input-begin-command', { claim: outcome.claim, span }) === true } if ('text' in outcome) { - return actx.bail(actx, 'slash/input-insert-text', { text: outcome.text, span }) === true + return actx.bail(actx, 'slash/input-insert-text', { + text: outcome.text, + span, + ...outcome.continue === true ? { continue: true } : {}, + }) === true } return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true } @@ -278,7 +283,12 @@ export class SlashController { const projection = this.project() for (const source of roster) { void source - .candidates(projection, { query: hit.query, position: hit.position, signal: controller.signal }) + .candidates(projection, { + query: hit.query, + quoted: hit.quoted, + position: hit.position, + signal: controller.signal, + }) .then( (items) => { if (controller.signal.aborted) return diff --git a/packages/client/ui-slash/src/core/contract.ts b/packages/client/ui-slash/src/core/contract.ts index 852ac2bf10..d877416060 100644 --- a/packages/client/ui-slash/src/core/contract.ts +++ b/packages/client/ui-slash/src/core/contract.ts @@ -11,6 +11,8 @@ export interface TriggerHit { readonly trigger: TriggerChar /** Text between the trigger char and the caret, live-filtered. */ readonly query: string + /** True only for an open quoted `@file` token. */ + readonly quoted: boolean /** leading = draft trimmed (whitespace incl. newlines) starts with the token. */ readonly position: TriggerPosition /** Token span; draftRev injected by the caller. */ @@ -19,8 +21,9 @@ export interface TriggerHit { /** * Detect a trigger token at the caret under the given guard tier. - * Word-boundary rule: the char before the trigger is start-of-line, - * whitespace, or punctuation; `user@host` and URL '/' do not trigger. + * `@` uses the shared file-reference start/whitespace grammar; `/` accepts + * punctuation boundaries with URL carve-outs. `user@host` and URL `/` do not + * trigger. * Returns null when no trigger is live at the caret. */ export type DetectTrigger = (draft: string, caret: number, guard: TriggerGuard) => TriggerHit | null diff --git a/packages/client/ui-slash/src/core/detect.ts b/packages/client/ui-slash/src/core/detect.ts index 5f2e43680c..4fa30788fa 100644 --- a/packages/client/ui-slash/src/core/detect.ts +++ b/packages/client/ui-slash/src/core/detect.ts @@ -3,6 +3,7 @@ * the caret for a live trigger char under the guard tier and applies the * word-boundary rules. Zero React / DOM / cordis. */ +import { activeAtToken } from '@deepseek-ai/dsh-file-reference/grammar' import type { TriggerChar } from '../types.ts' import type { DetectTrigger } from './contract.ts' @@ -29,10 +30,10 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean { } /** - * Detect a trigger token at the caret. Scans left from the caret and stops - * at the first whitespace (the token under edit never spans whitespace); - * trigger chars failing the guard tier or the word boundary are treated as - * ordinary token chars and the scan continues (`user@host`, URL slashes). + * Detect a trigger token at the caret. `@` first uses the shared grammar, + * including an open quoted token that may span whitespace. Slash detection + * scans left to the first whitespace; slashes failing the word boundary are + * treated as ordinary token chars and the scan continues (URL slashes). * Guard tiers: plain = both chars live; claimed = '/' fully suppressed, * '@' live; frozen = none. * @@ -46,15 +47,27 @@ function boundaryOk(draft: string, index: number, char: TriggerChar): boolean { */ export const detectTrigger: DetectTrigger = (draft, caret, guard) => { if (guard.tier === 'frozen') return null + const at = activeAtToken(draft, caret) + if (at !== undefined) { + const start = caret - at.prefix.length + return { + trigger: '@', + query: at.query, + quoted: at.quoted, + position: draft.search(/\S/) === start ? 'leading' : 'inline', + span: { start, end: caret, draftRev: 0 }, + } + } for (let i = caret - 1; i >= 0; i--) { const ch = draft.charAt(i) if (WHITESPACE.test(ch)) return null - if (ch !== '/' && ch !== '@') continue - if (guard.tier === 'claimed' && ch === '/') continue + if (ch !== '/') continue + if (guard.tier === 'claimed') continue if (!boundaryOk(draft, i, ch)) continue return { trigger: ch, query: draft.slice(i + 1, caret), + quoted: false, position: draft.search(/\S/) === i ? 'leading' : 'inline', span: { start: i, end: caret, draftRev: 0 }, } diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index 2fb26fa4b6..9209daae3d 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -1,6 +1,6 @@ /** * Frozen cross-package contract for the input trigger pipeline. Types only — - * no runtime code. Sources (ui-command / ui-skill / ui-subagent) and the + * no runtime code. Sources (ui-command / ui-skill / ui-reference) and the * conversation input layer import from here; changes require main-thread * arbitration. * @@ -33,6 +33,10 @@ export type PickVia = 'menu' | 'space' | 'enter' /** One menu candidate. Pure display data — zero behavior declaration. */ export interface SlashCandidate { readonly name: string + /** Source-owned stable value when the display name is not the identity. */ + readonly value?: string + /** Presentation-only heading rendered once for each contiguous section. */ + readonly section?: string readonly description?: string readonly icon?: string readonly hint?: string @@ -89,13 +93,15 @@ export interface SubmitOutcome { export type PickOutcome = | { readonly claim: CommandClaim } | { readonly insert: ReferenceInsert } - | { readonly text: string } + | { readonly text: string; readonly continue?: boolean } | 'handled' | undefined /** Candidate request passed to a source. The signal is superseded on query change / menu close. */ export interface CandidateRequest { readonly query: string + /** True only for the open `@"path with spaces` grammar. */ + readonly quoted?: boolean readonly position: TriggerPosition readonly signal: AbortSignal } @@ -204,6 +210,8 @@ export interface ConsumeTokenRequest { export interface InsertTextRequest { /** Literal replacement for the trigger token span (e.g. `/name `). */ readonly text: string + /** Re-run trigger detection after insertion (directory descent). */ + readonly continue?: boolean readonly span: TokenSpan } diff --git a/packages/client/ui-slash/tests/core-detect.spec.ts b/packages/client/ui-slash/tests/core-detect.spec.ts index 6d3326b5d8..cf84875960 100644 --- a/packages/client/ui-slash/tests/core-detect.spec.ts +++ b/packages/client/ui-slash/tests/core-detect.spec.ts @@ -91,6 +91,17 @@ describe('detectTrigger guard tiers', () => { }) describe('detectTrigger span and query', () => { + it('keeps an open quoted @file token active across spaces', () => { + const draft = 'read @"docs/design notes' + expect(atEnd(draft)).toMatchObject({ + trigger: '@', + query: 'docs/design notes', + quoted: true, + position: 'inline', + span: { start: 5, end: draft.length }, + }) + }) + it('spans trigger char to caret with a placeholder draftRev', () => { const hit = detectTrigger('say /goal', 9, plain) expect(hit?.span).toEqual({ start: 4, end: 9, draftRev: 0 }) diff --git a/packages/client/ui-slash/tests/core-menu.spec.ts b/packages/client/ui-slash/tests/core-menu.spec.ts index 28cd009948..55a1d3ef70 100644 --- a/packages/client/ui-slash/tests/core-menu.spec.ts +++ b/packages/client/ui-slash/tests/core-menu.spec.ts @@ -8,6 +8,7 @@ import { exactMatch, MENU_CLOSED, menuReduce, seedGroups } from '../src/core/men const hit = (query = ''): TriggerHit => ({ trigger: '/', query, + quoted: false, position: 'leading', span: { start: 0, end: 1 + query.length, draftRev: 1 }, }) diff --git a/packages/client/ui-slash/tests/menu-view.spec.tsx b/packages/client/ui-slash/tests/menu-view.spec.tsx index d9b6a08a88..d81cb698c4 100644 --- a/packages/client/ui-slash/tests/menu-view.spec.tsx +++ b/packages/client/ui-slash/tests/menu-view.spec.tsx @@ -14,6 +14,7 @@ import { MenuView } from '../src/client/MenuView.tsx' const hit: TriggerHit = { trigger: '/', query: 'g', + quoted: false, position: 'leading', span: { start: 0, end: 2, draftRev: 1 }, } @@ -60,6 +61,31 @@ describe('MenuView', () => { expect(screen.queryByText('Loading skill…')).not.toBeNull() }) + it('renders contiguous candidate sections once without changing option indexes', () => { + const { onPick } = mount(openState({ + groups: [{ + source: 'reference', + status: 'ready', + items: [ + { name: 'Folder · src/', section: '文件与文件夹' }, + { name: 'File · README.md', section: '文件与文件夹' }, + { name: 'Session · Research', section: 'Session 对话' }, + ], + }], + highlight: { source: 'reference', index: 0 }, + })) + expect(screen.getAllByText('文件与文件夹')).toHaveLength(1) + expect(screen.getAllByText('Session 对话')).toHaveLength(1) + const options = screen.getAllByRole('option') + expect(options.map(option => option.textContent)).toEqual([ + 'Folder · src/', + 'File · README.md', + 'Session · Research', + ]) + fireEvent.mouseDown(options[2]!) + expect(onPick).toHaveBeenCalledWith('reference', 2) + }) + it('exposes the highlight via aria-activedescendant and aria-selected', () => { mount(openState({ highlight: { source: 'command', index: 1 } })) const listbox = screen.getByRole('listbox') diff --git a/packages/client/ui-slash/tsconfig.json b/packages/client/ui-slash/tsconfig.json index a3002d4981..6057746631 100644 --- a/packages/client/ui-slash/tsconfig.json +++ b/packages/client/ui-slash/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../runtime" }, + { + "path": "../../context/file-reference" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md deleted file mode 100644 index 7a70add139..0000000000 --- a/packages/client/ui-subagent/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# @deepseek-ai/dsh-client-ui-subagent - -English | [中文](README.zh.md) - -Subagent reference source, browser half: registers the `@`-trigger `subagent` source into `ctx.slash`. Candidates are zero-RPC — filtered from the root `ctx.sessions.list` snapshot captured at registration (children of the per-call projection's session: `parentId` matches, `running`, `displayTitle` contains the query); picking a candidate lands the literal `@label ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` projects both faces as `@label` — the model serialization stays the raw label until the `@` consumption feature defines a model representation. The source implements no `matchSpace`/`matchEnter` hooks — subagent references never enter command adjudication and ride ordinary prompts into the default sink. - -A session with no running children is simply candidate-less. This phase ships "menu + reference text" only; what consuming an `@label` means (steering the child, resuming a disposed one) is future business work. - -The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect. - -## Model Experience - -### Subagent label text in the user prompt - -#### What the model sees - -A picked candidate lands the literal `@label` (the child session's display title) in the draft; the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side resolution. No consumption semantics exist yet: the model sees plain text and interprets it unaided. - -#### Token effect - -Conditional and tiny: only a pick (or hand-typing the same text) adds the label's characters to that one user message. Menu browsing adds zero model tokens (candidates never leave the browser). - -#### KV Cache effect - -Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens. - -## Known Limitations and Deferred Work - -- **`@` consumption semantics are unbuilt** — the reference is inert text; wiring it to steer/message the named child (and whether resuming a disposed child is allowed) awaits its own design decision in the ledger. -- **Candidates are running children only** — completed or disposed subagents never appear, and the roster is the scoped session's direct children (no grandchildren, no cross-session agents). -- **Labels are display titles, not stable ids** — two children sharing a display title produce indistinguishable references, and a title change orphans previously inserted text. Acceptable while references are inert; a consumption feature must bind to session ids. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md deleted file mode 100644 index 2d8ee677c7..0000000000 --- a/packages/client/ui-subagent/README.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# @deepseek-ai/dsh-client-ui-subagent - -[English](README.md) | 中文 - -subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running`、`displayTitle` 包含 query);pick 一个候选会把字面文本 `@label ` 经 slash 管线落进草稿(决策 21 的纯文本引用),source 的 `codec` 把两种投影都产出为 `@label`——在 `@` 消费功能定义模型表示之前,模型序列化保持原始 label。source 不实现 `matchSpace`/`matchEnter` 钩子——subagent 引用永不进入命令裁决,随普通提示词落入 default sink。 - -没有运行中子会话的会话就是没有候选。本阶段只交付「菜单 + 引用文本」;消费一个 `@label` 意味着什么(对子会话做 steering(中途引导)、恢复已 dispose 的子会话)是未来的业务工作。 - -`/client` 导出表层只有插件主体(`apply`/`inject`);source 对象是注册 effect 的内部实现。 - -## 模型体验 - -### 用户提示词中的 subagent label 文本 - -#### 模型所见 - -被 pick 的候选会把字面文本 `@label`(子会话的显示标题)落进草稿;该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧解析。目前不存在任何消费语义:模型看到的是纯文本,只能自行解读。 - -#### Token 影响 - -有条件且极小:只有 pick(或手动键入相同文本)会把 label 的字符加进那一条用户消息。浏览菜单增加零模型 token(候选永不离开浏览器)。 - -#### KV Cache 影响 - -仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。 - -## 已知限制与暂缓事项 - -- **`@` 消费语义尚未构建**:引用只是惰性文本;把它接到对指名子会话的 steering/发消息(以及是否允许恢复已 dispose 的子会话),等待台账中它自己的设计决策。 -- **候选只有运行中的子会话**:已完成或已 dispose 的 subagent 永不出现,roster 只含 scope 所指会话的直接子会话(不含孙辈,不含跨会话 agent)。 -- **label 是显示标题,不是稳定 id**:两个子会话共用一个显示标题时,产生的引用无法区分;标题变更会使先前插入的文本失去指向。引用还是惰性文本时可以接受;消费功能必须绑定到会话 id。 diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts deleted file mode 100644 index 3ad1543c68..0000000000 --- a/packages/client/ui-subagent/src/client/index.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Subagent reference plugin, browser half: registers the '@' source — - * candidates filtered from the session list snapshot's running children - * (zero RPC; the list rides the plugin's root-context sessions service, the - * scoped session comes from the per-call projection), pick inserts the - * literal `@label ` text (decision 21: the draft carries plain text, chip - * visuals are derived by scanning against the source lexicon, and the - * prompt ships the same literal). Consumption semantics stay with future - * business work (design ledger). No adjudication hooks: subagent - * references never enter command adjudication. - */ -import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' -import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' - -/** Required services: the slash registry + the session list face the source closes over. */ -export const inject = ['slash', 'sessions'] - -/** - * Client plugin body: register the '@' subagent source over the root session list. - * @param ctx - client root context. - */ -export function apply(ctx: ClientContext): void { - const sessions = ctx.get('sessions') as SessionsService - // Child labels live on the session list (parentId lineage + displayTitle), - // not the conversation snapshot — the list store is the zero-RPC candidate feed. - const childLabels = (session: ClientSessionContext, query: string): string[] => { - const { byId } = sessions.list.getSnapshot() - return Object.values(byId) - .filter(child => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query)) - .map(child => child.displayTitle) - } - const source: SlashSource = { - trigger: '@', - name: 'subagent', - candidates(session, { query }) { - return Promise.resolve(childLabels(session, query).map(name => ({ name }))) - }, - lexicon(session) { - // The list snapshot is always warm — the full running-children roster. - return childLabels(session, '') - }, - onPick({ candidate }) { - // Decision 21: plain-text reference — the literal lands in the draft - // and ships to the model verbatim (trailing space closes the token). - // Legacy path (decision 21), retained for the removal cut, no longer reached: - // return { insert: { source: 'subagent', ref: candidate.name, label: candidate.name, clipboardText: `@${candidate.name}` } } - return { text: `@${candidate.name} ` } - }, - codec: { - clipboardText: ref => `@${ref}`, - // TODO: serialize returns the raw label until the '@' consumption - // feature defines a model representation (design ledger). - serialize: ref => Promise.resolve(`@${ref}`), - }, - } - const slash = ctx.get('slash') as SlashServiceContract - ctx.effect(() => slash.registerSource(source), 'ui-subagent: @ source') -} diff --git a/packages/client/ui-subagent/src/css-modules.d.ts b/packages/client/ui-subagent/src/css-modules.d.ts deleted file mode 100644 index bc5e482353..0000000000 --- a/packages/client/ui-subagent/src/css-modules.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module '*.module.css' { - const classes: Record - export default classes -} - -declare module '*.css' diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts deleted file mode 100644 index fc74470406..0000000000 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * ui-subagent browser half: source registration (duplicate-name proof) + - * fiber-teardown removal (HMR safety) against the real SlashService, then - * the source behavior contract driven directly on the captured source with - * real ClientSessionContext projections — zero-RPC candidates from the root - * session list (running children of the projected session, label-contains - * filtering, childless session → empty), the synchronous lexicon roster, - * pick → plain-text outcome (decision 21), and the reference codec's two - * projections. Direct driving is deliberate: this spec owns only the - * source's own contract. - */ -import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' -import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' -import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' -import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' -import { apply, inject } from '../src/client/index.ts' - -function summary(partial: Partial & { id: SessionId }): SessionSummary { - return { - displayTitle: partial.id, - running: false, - updatedAt: 0, - ...partial, - } as SessionSummary -} - -const sid = (id: string) => id as SessionId - -/** Fake root sessions face: the list snapshot the source closes over. */ -function sessionsWith(sessions: SessionSummary[]) { - const byId: Record = {} - for (const s of sessions) byId[s.id] = s - const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState - return { list: { getSnapshot: () => snapshot } } -} - -/** Boot the plugin over fake slash/sessions faces; returns the captured source. */ -async function bench(sessions: SessionSummary[]): Promise { - const ctx = new Context() - let captured: SlashSource | undefined - ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('sessions', sessionsWith(sessions)) - await ctx.plugin({ inject: [...inject], apply }).await() - return captured! -} - -const FAMILY: SessionSummary[] = [ - summary({ id: sid('parent'), displayTitle: 'parent', running: true }), - summary({ id: sid('c1'), parentId: sid('parent'), displayTitle: 'worker-1', running: true }), - summary({ id: sid('c2'), parentId: sid('parent'), displayTitle: 'worker-2', running: true }), - // Filtered out: not running / other parent / label miss. - summary({ id: sid('c3'), parentId: sid('parent'), displayTitle: 'worker-3', running: false }), - summary({ id: sid('c4'), parentId: sid('other'), displayTitle: 'worker-4', running: true }), - summary({ id: sid('c5'), parentId: sid('parent'), displayTitle: 'scout', running: true }), -] - -const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) }) - -const req = (query: string) => - ({ query, position: 'inline' as const, signal: new AbortController().signal }) - -describe('apply', () => { - it('declares the services it binds', () => { - expect(inject).toEqual(['slash', 'sessions']) - }) - - it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => { - const ctx = new Context() - await ctx.plugin(SlashService).await() - ctx.provide('sessions', sessionsWith(FAMILY)) - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() - const slash = ctx.get('slash') as SlashService - const rival = { - trigger: '@' as const, - name: 'subagent', - candidates: () => Promise.resolve([]), - onPick: () => undefined, - } - // Live registration holds the (trigger, name) seat… - expect(() => slash.registerSource(rival)).toThrow(/already registered/) - // …and fiber teardown releases it. - await fiber.dispose() - expect(() => slash.registerSource(rival)).not.toThrow() - }) -}) - -describe('candidates', () => { - it('returns running children of the projected session, filtered by label containment', async () => { - const source = await bench(FAMILY) - await expect(source.candidates(proj('parent'), req('worker'))).resolves.toEqual([ - { name: 'worker-1' }, { name: 'worker-2' }, - ]) - }) - - it('matches every running child on an empty query (containment, not prefix)', async () => { - const source = await bench(FAMILY) - await expect(source.candidates(proj('parent'), req(''))).resolves.toEqual([ - { name: 'worker-1' }, { name: 'worker-2' }, { name: 'scout' }, - ]) - }) - - it('is candidate-less for a session with no children', async () => { - const source = await bench(FAMILY) - await expect(source.candidates(proj('childless'), req(''))).resolves.toEqual([]) - }) -}) - -describe('lexicon', () => { - it('synchronously serves the projected session\'s full running-children roster', async () => { - const source = await bench(FAMILY) - expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout']) - expect(source.lexicon!(proj('childless'))).toEqual([]) - }) -}) - -describe('pick and codec', () => { - it('onPick returns the literal @label text with a closing space (decision 21)', async () => { - const source = await bench(FAMILY) - const outcome = source.onPick({ - candidate: { name: 'worker-1' }, - session: proj('parent'), - position: 'inline', - via: 'menu', - span: { start: 4, end: 8, draftRev: 3 }, - }) - expect(outcome).toEqual({ text: '@worker-1 ' }) - }) - - it('codec projects clipboard `@label` and serializes the same raw label this phase', async () => { - const source = await bench(FAMILY) - expect(source.codec!.clipboardText('worker-1')).toBe('@worker-1') - await expect(source.codec!.serialize('worker-1', new AbortController().signal)) - .resolves.toBe('@worker-1') - }) -}) - -describe('adjudication', () => { - it('never participates: no matchSpace/matchEnter hooks on the subagent source', async () => { - const source = await bench(FAMILY) - expect('matchSpace' in source && source.matchSpace !== undefined).toBe(false) - expect('matchEnter' in source && source.matchEnter !== undefined).toBe(false) - }) -}) diff --git a/packages/client/ui-subagent/tsdown.config.ts b/packages/client/ui-subagent/tsdown.config.ts deleted file mode 100644 index 71078e15a2..0000000000 --- a/packages/client/ui-subagent/tsdown.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { clientBundle } from '../tsdown.client.ts' - -export default clientBundle('@deepseek-ai/dsh-client-ui-subagent', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/context/file-reference-local/README.i18n.yaml b/packages/context/file-reference-local/README.i18n.yaml new file mode 100644 index 0000000000..475baf10db --- /dev/null +++ b/packages/context/file-reference-local/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/context/file-reference-local/README.md +README.md: 67b07eef4b59fdcc5e21104cac4628feb1c15f4e +README.zh.md: beded13250daf041294e4e2656d0e1374407ff94 diff --git a/packages/context/file-reference-local/README.md b/packages/context/file-reference-local/README.md new file mode 100644 index 0000000000..67b07eef4b --- /dev/null +++ b/packages/context/file-reference-local/README.md @@ -0,0 +1,45 @@ +# `@deepseek-ai/dsh-file-reference-local` + +English | [中文](README.zh.md) + +Local-filesystem implementation of `ctx.fileReferences`. It maintains one bounded `WorkspaceFileSearch` per agent, rooted at that session's `cwd` and falling back to the host process cwd. The index ranks direct directory listings for queries containing `/`, otherwise fuzzy-ranks a bounded recursive index; it never follows directory symlinks. + +Tool-result events invalidate the addressed agent's reusable index so later completion observes likely workspace mutations. Agent disposal releases that index and its scoped prompt contribution; plugin disposal awaits every prompt fiber and releases all cached searches. + +## Configuration + +| Key | Default | Contract | +|---|---:|---| +| `maxResults` | `20` | Maximum ranked candidates returned for one query. | +| `maxEntries` | `10000` | Maximum files and directories indexed per agent workspace. | +| `excludedDirectories` | `[".git", "node_modules"]` | Directory basenames omitted from traversal and candidates. | + +Every numeric value must be a positive safe integer. Excluded names must be non-empty basenames without `/` or `\`. + +## Model Experience + +### File-reference guidance when `read` is available + +#### What the model sees + +When the addressed agent has an effective `read` tool, the provider contributes this stable system-prompt section: + +##### File-reference instruction + +```markdown +Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +``` + +#### Token effect + +Conditional and fixed: the one sentence is present while `read` is visible to the addressed agent; candidate lookup itself adds no tokens, and a selected path contributes only its ordinary user-message characters. + +#### KV Cache effect + +The stable sentence joins the system-prompt prefix. Mounting or removing this provider, or changing whether `read` is visible, changes that prefix; queries, candidates, and index invalidations do not. + +## Known Limitations and Deferred Work + +- **Host-local namespace** — the provider scans the Harness host filesystem, so remote or virtual `read` implementations require a provider whose namespace matches the tool. +- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. +- **No ignore-file semantics** — `.gitignore` and other project ignore files do not influence discovery; only configured directory basenames are excluded. diff --git a/packages/context/file-reference-local/README.zh.md b/packages/context/file-reference-local/README.zh.md new file mode 100644 index 0000000000..beded13250 --- /dev/null +++ b/packages/context/file-reference-local/README.zh.md @@ -0,0 +1,45 @@ +# `@deepseek-ai/dsh-file-reference-local` + +[English](README.md) | 中文 + +`ctx.fileReferences` 的本地文件系统实现。它为每个 agent(智能体)维护一个有界的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根目录;缺少该值时回退到宿主进程的 cwd。查询包含 `/` 时,索引会对直接列出的目录项排序;否则会对有界递归索引进行模糊排序。索引永远不会跟随目录符号链接。 + +工具结果事件会使指定 agent 的可复用索引失效,使后续补全能够反映工作区中可能发生的变更。agent 的 dispose(资源释放)会释放该索引及其作用域内的提示词贡献;插件 dispose 会等待所有提示词 fiber,并释放全部缓存的搜索器。 + +## 配置 + +| 配置键 | 默认值 | 契约 | +|---|---:|---| +| `maxResults` | `20` | 单次查询返回的候选项最大数量。 | +| `maxEntries` | `10000` | 每个 agent 工作区建立索引的文件和目录最大数量。 | +| `excludedDirectories` | `[".git", "node_modules"]` | 遍历和候选项中排除的目录基名。 | + +所有数值都必须是正的安全整数。排除名称必须是非空基名,且不能包含 `/` 或 `\`。 + +## 模型体验 + +### `read` 可用时的文件引用指引 + +#### 模型看到什么 + +当指定 agent 有实际生效的 `read` 工具时,提供方会贡献以下稳定的系统提示词段: + +##### 文件引用指令 + +```markdown +Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it. +``` + +#### Token 影响 + +该影响有条件且固定:只要 `read` 对指定 agent 可见,这一句就会存在;候选查询本身不增加 token,所选路径只会贡献普通用户消息中的对应字符。 + +#### KV 缓存影响 + +该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引失效不会改变前缀。 + +## 已知限制与暂缓事项 + +- **宿主本地命名空间**:提供方扫描 Harness 宿主的文件系统,因此远程或虚拟 `read` 实现需要使用命名空间与该工具一致的提供方。 +- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。 +- **没有忽略文件语义**:`.gitignore` 和其他项目忽略文件不会影响发现;系统只排除已配置的目录基名。 diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json new file mode 100644 index 0000000000..feb4cecfbf --- /dev/null +++ b/packages/context/file-reference-local/package.json @@ -0,0 +1,53 @@ +{ + "name": "@deepseek-ai/dsh-file-reference-local", + "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", + "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" + }, + "./search": { + "types": "./lib/types/search.d.ts", + "default": "./lib/types/search.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-file-reference": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/context/file-reference-local/src/index.ts b/packages/context/file-reference-local/src/index.ts new file mode 100644 index 0000000000..60fa744058 --- /dev/null +++ b/packages/context/file-reference-local/src/index.ts @@ -0,0 +1,140 @@ +/** + * Local-filesystem implementation of `ctx.fileReferences`. + * + * @module @deepseek-ai/dsh-file-reference-local + */ + +import { Context } from 'cordis' +import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import FileReferenceService, { + FILE_REFERENCE_PROMPT, + type FileReferenceCandidate, +} from '@deepseek-ai/dsh-file-reference' +import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-tools' +import { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, + WorkspaceFileSearch, + type FileSearchConfig, +} from './search.ts' + +export { + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + DEFAULT_FILE_SEARCH_MAX_ENTRIES, + DEFAULT_FILE_SEARCH_MAX_RESULTS, + WorkspaceFileSearch, +} from './search.ts' +export type { FileSearchConfig } from './search.ts' +export { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference' +export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' + +/** Local file-reference discovery configuration. */ +export interface Config { + /** Maximum ranked candidates returned for one query. */ + maxResults?: number + /** Maximum indexed files and directories per agent workspace. */ + maxEntries?: number + /** Directory basenames never traversed or offered. */ + excludedDirectories?: string[] +} + +/** Local-filesystem owner of the file-reference discovery service. */ +export class LocalFileReferenceService extends FileReferenceService { + static inject = ['agents'] + static Config: z = z.object({ + maxResults: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS), + maxEntries: z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES), + excludedDirectories: z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]), + }) + + private readonly config: FileSearchConfig + private readonly searches = new Map() + private readonly promptFibers = new Map>() + private readonly promptDisposals = new Set>() + + constructor(ctx: Context, config: Config = {}) { + super(ctx) + this.config = { + maxResults: config.maxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, + maxEntries: config.maxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, + excludedDirectories: config.excludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, + } + validateConfig(this.config) + + const installPrompt = (agent: Agent): void => { + if (this.promptFibers.has(agent)) return + const fiber = agent.ctx.inject(['systemPrompt', 'tools'], (scope) => { + scope.systemPrompt.section({ + name: 'context:file-reference', + order: 99, + text: () => agent.ctx.tools.get('read', agent) === undefined ? '' : FILE_REFERENCE_PROMPT, + }) + }) + this.promptFibers.set(agent, fiber) + } + const disposePrompt = (agent: Agent): void => { + const fiber = this.promptFibers.get(agent) + if (fiber === undefined) return + this.promptFibers.delete(agent) + const task = fiber.dispose().catch((error: unknown) => { + ctx.logger.warn(`file-reference-local: prompt cleanup failed: ${error instanceof Error ? error.message : String(error)}`) + }) + this.promptDisposals.add(task) + void task.finally(() => { + this.promptDisposals.delete(task) + }) + } + for (const agent of ctx.agents.list()) installPrompt(agent) + ctx.on('agent/created', installPrompt) + ctx.on('agent/disposed', (agent) => { + this.searches.get(agent)?.dispose() + this.searches.delete(agent) + disposePrompt(agent) + }) + ctx.on('session/event', (session, event) => { + if (event.type !== 'tool/result') return + const agent = ctx.agents.get(session.id) + if (agent !== undefined) this.searches.get(agent)?.invalidate() + }) + ctx.effect(() => async () => { + for (const search of this.searches.values()) search.dispose() + this.searches.clear() + const promptFibers = [...this.promptFibers.values()] + this.promptFibers.clear() + await Promise.all([ + ...promptFibers.map(fiber => fiber.dispose()), + ...this.promptDisposals, + ]) + }, 'file-reference-local: search cache') + } + + override list( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + let search = this.searches.get(agent) + if (search === undefined) { + search = new WorkspaceFileSearch(agent.session.header.cwd ?? process.cwd(), this.config) + this.searches.set(agent, search) + } + return search.list(query, signal) + } +} + +function validateConfig(config: FileSearchConfig): void { + if (!Number.isSafeInteger(config.maxResults) || config.maxResults <= 0) { + throw new Error('file-reference-local: maxResults must be a positive safe integer') + } + if (!Number.isSafeInteger(config.maxEntries) || config.maxEntries <= 0) { + throw new Error('file-reference-local: maxEntries must be a positive safe integer') + } + if (config.excludedDirectories.some(name => name.length === 0 || name.includes('/') || name.includes('\\'))) { + throw new Error('file-reference-local: excludedDirectories entries must be non-empty directory basenames') + } +} + +export default LocalFileReferenceService diff --git a/packages/context/file-reference-local/src/invariant.ts b/packages/context/file-reference-local/src/invariant.ts new file mode 100644 index 0000000000..c66f64e520 --- /dev/null +++ b/packages/context/file-reference-local/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-file-reference-local`. + * @module @deepseek-ai/dsh-file-reference-local/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-file-reference-local' + +/** Cordis companion plugin name. */ +export const name = 'file-reference-local-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: per-agent indexes are private advisory caches whose + * invalidation and disposal are observed directly through service tests. + */ +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/ui/tui/src/file-autocomplete.ts b/packages/context/file-reference-local/src/search.ts similarity index 78% rename from packages/ui/tui/src/file-autocomplete.ts rename to packages/context/file-reference-local/src/search.ts index 23a3a7c1fa..a0257a5d33 100644 --- a/packages/ui/tui/src/file-autocomplete.ts +++ b/packages/context/file-reference-local/src/search.ts @@ -1,13 +1,16 @@ /** - * Host-workspace discovery for TUI `@file` completion. The index contains - * paths only: selected values remain ordinary prompt text and file contents - * stay behind the model-facing `read` tool. + * Host-workspace discovery for `@file` completion. The index contains paths + * only: selected values remain ordinary prompt text and file contents stay + * behind the model-facing `read` tool. * - * @module @deepseek-ai/dsh-tui/file-autocomplete + * @module @deepseek-ai/dsh-file-reference-local/search */ import { lstat, readdir } from 'node:fs/promises' import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference' + +export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' /** Default maximum file and directory candidates rendered for one query. */ export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20 @@ -16,7 +19,7 @@ export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000 /** Directory basenames omitted from traversal unless the deployment overrides them. */ export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const -/** Resolved limits and exclusions for one TUI workspace index. */ +/** Resolved limits and exclusions for one workspace index. */ export interface FileSearchConfig { /** Maximum ranked candidates returned for one query. */ maxResults: number @@ -26,28 +29,10 @@ export interface FileSearchConfig { excludedDirectories: readonly string[] } -/** One path-only completion candidate inside the session cwd. */ -export interface FileSearchCandidate { - /** User-facing path accepted by the normal prompt and filesystem tools. */ - path: string - /** Directories keep completion open; files finish the mention. */ - kind: 'file' | 'directory' -} - -/** Active `@` token ending at the editor cursor. */ -export interface ActiveAtToken { - /** Complete token replaced when the user accepts a completion. */ - prefix: string - /** Path query after `@` or `@"`. */ - query: string - /** Whether the user opened a quoted path. */ - quoted: boolean -} - -interface IndexedPath extends FileSearchCandidate {} +interface IndexedPath extends FileReferenceCandidate {} interface RankedPath { - candidate: FileSearchCandidate + candidate: FileReferenceCandidate score: number } @@ -56,43 +41,6 @@ interface IndexGeneration { promise: Promise } -/** - * Extract an `@path` or `@"path with spaces` token at the cursor. An `@` - * inside another token, such as an email address, is not a completion trigger. - * @param line - current editor line. - * @param cursorCol - cursor column within that line. - * @returns the active token, or `undefined` outside an `@` token. - */ -export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined { - const beforeCursor = line.slice(0, cursorCol) - const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor) - if (quoted?.[1] !== undefined && quoted[2] !== undefined) { - return { prefix: quoted[1], query: quoted[2], quoted: true } - } - const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor) - if (plain?.[1] === undefined || plain[2] === undefined) return undefined - return { prefix: plain[1], query: plain[2], quoted: false } -} - -/** - * Format a selected path as prompt text. Whitespace uses Pi's quoted - * `@"path"` grammar; directories retain a trailing slash so completion can - * descend another level. - * @param candidate - selected file or directory. - * @param preserveQuote - retain an explicitly opened quote even when unnecessary. - * @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely. - */ -export function formatFileMention( - candidate: FileSearchCandidate, - preserveQuote: boolean, -): string | undefined { - const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path - if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined - const quoted = preserveQuote || /\s/u.test(path) - if (!quoted) return `@${path}` - return `@"${path}"` -} - /** * Cancellable, reusable fuzzy index rooted at one agent working directory. * Directory-scoped queries list live state; bare fuzzy queries share one @@ -125,7 +73,7 @@ export class WorkspaceFileSearch { * @param signal - cancels this caller's wait without killing an index shared by a newer query. * @returns at most `maxResults` deterministic candidates. */ - async list(rawQuery: string, signal: AbortSignal): Promise { + async list(rawQuery: string, signal: AbortSignal): Promise { signal.throwIfAborted() if (this.disposed) return [] const query = rawQuery.replaceAll('\\', '/') @@ -203,12 +151,12 @@ export class WorkspaceFileSearch { displayDirectory: string, fragment: string, signal: AbortSignal, - ): Promise { + ): Promise { if (displayDirectory.split('/').some(segment => this.excludedDirectories.has(segment))) return [] const absolute = await resolveDisplayDirectory(this.root, displayDirectory, signal) if (absolute === undefined) return [] const entries = await readDirectory(absolute, signal) - const candidates: FileSearchCandidate[] = [] + const candidates: FileReferenceCandidate[] = [] for (const entry of entries) { if (entry.name.startsWith('.') && !fragment.startsWith('.')) continue if (entry.isDirectory()) { @@ -269,10 +217,10 @@ function visibleForGlobalQuery(path: string, query: string): boolean { } function rankCandidates( - candidates: readonly FileSearchCandidate[], + candidates: readonly FileReferenceCandidate[], query: string, limit: number, -): FileSearchCandidate[] { +): FileReferenceCandidate[] { const ranked: RankedPath[] = [] for (const candidate of candidates) { const score = scoreCandidate(candidate, query) @@ -286,7 +234,7 @@ function rankCandidates( return ranked.slice(0, limit).map(entry => entry.candidate) } -function scoreCandidate(candidate: FileSearchCandidate, query: string): number | undefined { +function scoreCandidate(candidate: FileReferenceCandidate, query: string): number | undefined { if (query === '') return 0 const path = candidate.path.toLowerCase() const name = path.slice(path.lastIndexOf('/') + 1) @@ -312,7 +260,7 @@ function subsequenceScore(target: string, query: string): number | undefined { return Math.max(0, 100 - gap) } -function kindRank(kind: FileSearchCandidate['kind']): number { +function kindRank(kind: FileReferenceCandidate['kind']): number { return kind === 'directory' ? 0 : 1 } diff --git a/packages/context/file-reference-local/tests/invariant.spec.ts b/packages/context/file-reference-local/tests/invariant.spec.ts new file mode 100644 index 0000000000..3b06290d45 --- /dev/null +++ b/packages/context/file-reference-local/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as FileReferenceLocalInvariant from '../src/invariant.ts' + +describe('invariant companion', () => { + it('registers the provider cache ownership under its package name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(FileReferenceLocalInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/ui/tui/tests/file-autocomplete.spec.ts b/packages/context/file-reference-local/tests/search.spec.ts similarity index 97% rename from packages/ui/tui/tests/file-autocomplete.spec.ts rename to packages/context/file-reference-local/tests/search.spec.ts index 53dd1f4f1a..98bdd8cf66 100644 --- a/packages/ui/tui/tests/file-autocomplete.spec.ts +++ b/packages/context/file-reference-local/tests/search.spec.ts @@ -6,7 +6,7 @@ import { activeAtToken, formatFileMention, WorkspaceFileSearch, -} from '../src/file-autocomplete.ts' +} from '../src/search.ts' const searches: WorkspaceFileSearch[] = [] const roots: string[] = [] @@ -48,7 +48,7 @@ afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) -describe('TUI file autocomplete grammar', () => { +describe('file-reference grammar', () => { it('recognizes boundary and quoted mentions without treating emails as references', () => { expect(activeAtToken('@src/tu', 7)).toEqual({ prefix: '@src/tu', query: 'src/tu', quoted: false }) expect(activeAtToken('read @"docs/design n', 20)).toEqual({ @@ -65,6 +65,8 @@ describe('TUI file autocomplete grammar', () => { expect(formatFileMention({ path: 'src', kind: 'directory' }, false)).toBe('@src/') expect(formatFileMention({ path: 'docs/design notes.md', kind: 'file' }, false)) .toBe('@"docs/design notes.md"') + expect(formatFileMention({ path: 'docs/design notes', kind: 'directory' }, false)) + .toBe('@"docs/design notes/') expect(formatFileMention({ path: 'README.md', kind: 'file' }, true)).toBe('@"README.md"') expect(formatFileMention({ path: 'bad\nname', kind: 'file' }, false)).toBeUndefined() expect(formatFileMention({ path: 'bad "name".md', kind: 'file' }, false)).toBeUndefined() diff --git a/packages/context/file-reference-local/tests/service.spec.ts b/packages/context/file-reference-local/tests/service.spec.ts new file mode 100644 index 0000000000..f1c7bd59bf --- /dev/null +++ b/packages/context/file-reference-local/tests/service.spec.ts @@ -0,0 +1,161 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import AgentRegistry, { AgentMessageId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference' +import LocalFileReferenceService, { WorkspaceFileSearch } from '../src/index.ts' + +const roots: string[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + return ctx +} + +async function stubAgent( + ctx: Context, + id = 'file-reference-agent', + includeCwd = true, +): Promise<{ agent: Agent; dispose: () => void }> { + const root = await mkdtemp(join(tmpdir(), 'dsh-file-reference-service-')) + roots.push(root) + await writeFile(join(root, 'README.md'), 'readme') + const session = ctx.sessions.create(SessionId(id), { meta: includeCwd ? { cwd: root } : {} }) + const agent = { + id: session.id, + options: {}, + session, + status: 'idle', + ctx, + followup: () => AgentMessageId('followup'), + queue: () => AgentMessageId('queue'), + steer: () => AgentMessageId('steer'), + inject: () => AgentMessageId('inject'), + send: () => AgentMessageId('send'), + cancel() {}, + whenIdle: () => Promise.resolve(), + } as Agent + return { agent, dispose: ctx.agents.register(agent) } +} + +describe('LocalFileReferenceService', () => { + it('serves the addressed workspace and installs read-tool guidance for existing agents', async () => { + const ctx = await harness() + const { agent } = await stubAgent(ctx) + const fiber = ctx.plugin(LocalFileReferenceService, { + maxResults: 5, + maxEntries: 100, + excludedDirectories: ['.git'], + }) + await fiber + await expect(ctx.fileReferences.list(agent, 'README', new AbortController().signal)) + .resolves.toEqual([{ path: 'README.md', kind: 'file' }]) + expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT) + + ctx.tools.register(defineContentToolFixture({ + name: 'read', + description: 'read a file', + parameters: {}, + execute: () => Promise.resolve([]), + })) + expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(FILE_REFERENCE_PROMPT) + await fiber.dispose() + expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain(FILE_REFERENCE_PROMPT) + }) + + it('invalidates cached searches after tool results and disposes them with the agent', async () => { + const ctx = await harness() + const { agent, dispose } = await stubAgent(ctx) + const invalidate = vi.spyOn(WorkspaceFileSearch.prototype, 'invalidate') + const close = vi.spyOn(WorkspaceFileSearch.prototype, 'dispose') + await ctx.plugin(LocalFileReferenceService) + await ctx.fileReferences.list(agent, 'README', new AbortController().signal) + + ctx.emit('session/event', agent.session, { type: 'tool/result' } as never) + expect(invalidate).toHaveBeenCalledOnce() + ctx.emit('session/event', agent.session, { type: 'assistant/message' } as never) + expect(invalidate).toHaveBeenCalledOnce() + const orphan = ctx.sessions.create(SessionId('file-reference-orphan')) + ctx.emit('session/event', orphan, { type: 'tool/result' } as never) + expect(invalidate).toHaveBeenCalledOnce() + + dispose() + expect(close).toHaveBeenCalledOnce() + ctx.emit('agent/disposed', agent) + }) + + it('installs guidance for agents announced after the service and validates deployment tunables', async () => { + const ctx = await harness() + await ctx.plugin(LocalFileReferenceService) + const { agent } = await stubAgent(ctx) + await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)) + .resolves.toEqual([{ path: 'README.md', kind: 'file' }]) + + const badResults = await harness() + expect(() => new LocalFileReferenceService(badResults, { maxResults: 0 })).toThrow('maxResults') + const badEntries = await harness() + expect(() => new LocalFileReferenceService(badEntries, { maxEntries: 1.5 })).toThrow('maxEntries') + const badExclusion = await harness() + expect(() => new LocalFileReferenceService(badExclusion, { excludedDirectories: ['nested/name'] })) + .toThrow('excludedDirectories') + const fractionalResults = await harness() + expect(() => new LocalFileReferenceService(fractionalResults, { maxResults: 1.5 })).toThrow('maxResults') + const zeroEntries = await harness() + expect(() => new LocalFileReferenceService(zeroEntries, { maxEntries: 0 })).toThrow('maxEntries') + const emptyExclusion = await harness() + expect(() => new LocalFileReferenceService(emptyExclusion, { excludedDirectories: [''] })) + .toThrow('excludedDirectories') + const backslashExclusion = await harness() + expect(() => new LocalFileReferenceService(backslashExclusion, { excludedDirectories: ['nested\\name'] })) + .toThrow('excludedDirectories') + }) + + it('deduplicates lifecycle announcements and falls back to the process cwd', async () => { + const ctx = await harness() + const fiber = ctx.plugin(LocalFileReferenceService) + await fiber + const { agent } = await stubAgent(ctx, 'cwd-fallback', false) + ctx.emit('agent/created', agent) + const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockResolvedValue([]) + await expect(ctx.fileReferences.list(agent, '', new AbortController().signal)).resolves.toEqual([]) + await expect(ctx.fileReferences.list(agent, 'src', new AbortController().signal)).resolves.toEqual([]) + expect(list).toHaveBeenCalledTimes(2) + }) + + it('logs rejected prompt cleanup without failing service teardown', async () => { + const ctx = await harness() + const fiber = ctx.plugin(LocalFileReferenceService) + await fiber + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const inject = vi.spyOn(ctx, 'inject') + .mockReturnValueOnce({ dispose: () => Promise.reject(new Error('error cleanup')) } as never) + // Deliberately proves cleanup tolerates JavaScript callers rejecting non-Error values. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + .mockReturnValueOnce({ dispose: () => Promise.reject('string cleanup') } as never) + const first = await stubAgent(ctx, 'cleanup-one') + const second = await stubAgent(ctx, 'cleanup-two') + expect(inject).toHaveBeenCalledTimes(2) + first.dispose() + second.dispose() + await vi.waitFor(() => { + expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: error cleanup') + expect(warn).toHaveBeenCalledWith('file-reference-local: prompt cleanup failed: string cleanup') + }) + await expect(fiber.dispose()).resolves.toBeUndefined() + }) +}) diff --git a/packages/context/file-reference-local/tsconfig.json b/packages/context/file-reference-local/tsconfig.json new file mode 100644 index 0000000000..9aeae5fabf --- /dev/null +++ b/packages/context/file-reference-local/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../file-reference" + } + ] +} diff --git a/packages/context/file-reference/README.i18n.yaml b/packages/context/file-reference/README.i18n.yaml new file mode 100644 index 0000000000..de92c41665 --- /dev/null +++ b/packages/context/file-reference/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/context/file-reference/README.md +README.md: c65c787c2143ba88f2ac9887065b537c23f67ca2 +README.zh.md: 4c0d955bd6804f17ee99a5a65138f39391adfc95 diff --git a/packages/context/file-reference/README.md b/packages/context/file-reference/README.md new file mode 100644 index 0000000000..c65c787c21 --- /dev/null +++ b/packages/context/file-reference/README.md @@ -0,0 +1,22 @@ +# `@deepseek-ai/dsh-file-reference` + +English | [中文](README.zh.md) + +File-reference discovery seam and browser-safe `@file` grammar shared by host-backed user interfaces. `ctx.fileReferences.list(agent, query, signal)` returns path-only file or directory candidates for the addressed agent; concrete providers own namespace access, ranking, caching, and invalidation. + +`activeAtToken()` recognizes an `@path` or open `@"path with spaces` token only at the start of input or after whitespace, so email-like text does not open completion. `formatFileMention()` emits the matching prompt spelling, appends `/` to directory candidates, preserves an explicitly opened quote, and rejects control characters or embedded quotes that the editor grammar cannot represent safely. + +Selecting a candidate does not read or attach file contents. The exported `FILE_REFERENCE_PROMPT` is stable guidance that a provider may install when the addressed agent can call `read`. + +## Model Experience + +Indirectly, through `@deepseek-ai/dsh-file-reference-local`, which conditionally contributes this package's stable file-reference guidance. + +#### KV Cache effect + +The interface and grammar add no request tokens themselves; a provider-owned prompt section determines cache behavior. + +## Known Limitations and Deferred Work + +- **Path candidates are advisory** — the seam does not prove that a later model-facing filesystem tool can access the same namespace; deployments must align the provider with the effective `read` implementation. +- **No file-content reference object** — selected files remain ordinary prompt text and require an explicit model tool call before their contents become model-visible. diff --git a/packages/context/file-reference/README.zh.md b/packages/context/file-reference/README.zh.md new file mode 100644 index 0000000000..4c0d955bd6 --- /dev/null +++ b/packages/context/file-reference/README.zh.md @@ -0,0 +1,22 @@ +# `@deepseek-ai/dsh-file-reference` + +[English](README.md) | 中文 + +文件引用发现 seam,以及供宿主驱动的用户界面共享、可在浏览器中安全使用的 `@file` 语法。`ctx.fileReferences.list(agent, query, signal)` 为指定 agent(智能体)返回仅含路径的文件或目录候选;具体提供方负责命名空间访问、排序、缓存和失效处理。 + +`activeAtToken()` 只在输入开头或空白后识别 `@path` 或尚未闭合的 `@"path with spaces` token,因此类似电子邮件的文本不会打开补全。`formatFileMention()` 会生成与提示词匹配的写法,为目录候选追加 `/`,保留显式打开的引号,并拒绝编辑器语法无法安全表示的控制字符或内嵌引号。 + +选择候选项不会读取或附加文件内容。导出的 `FILE_REFERENCE_PROMPT` 是稳定指引;当指定 agent 可以调用 `read` 时,提供方可以安装该指引。 + +## 模型体验 + +间接影响模型体验:`@deepseek-ai/dsh-file-reference-local` 会按条件贡献本包的稳定文件引用指引。 + +#### KV 缓存影响 + +接口和语法本身不会增加请求 token;缓存行为取决于提供方拥有的提示词段。 + +## 已知限制与暂缓事项 + +- **路径候选仅供参考**:该 seam 不保证后续面向模型的文件系统工具能够访问同一命名空间;部署时必须让提供方与实际生效的 `read` 实现对齐。 +- **没有文件内容引用对象**:所选文件仍是普通提示词文本,其内容必须经过模型显式调用工具后才对模型可见。 diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json new file mode 100644 index 0000000000..be82120f4b --- /dev/null +++ b/packages/context/file-reference/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-file-reference", + "description": "File-reference discovery contract and shared @file grammar", + "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" + }, + "./grammar": { + "types": "./lib/types/grammar.d.ts", + "default": "./lib/types/grammar.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/context/file-reference/src/grammar.ts b/packages/context/file-reference/src/grammar.ts new file mode 100644 index 0000000000..3e74248f32 --- /dev/null +++ b/packages/context/file-reference/src/grammar.ts @@ -0,0 +1,55 @@ +/** + * Browser-safe `@file` token grammar shared by terminal and web clients. + * + * @module @deepseek-ai/dsh-file-reference/grammar + */ + +import type { FileReferenceCandidate } from './index.ts' + +/** Active `@` token ending at the editor cursor. */ +export interface ActiveAtToken { + /** Complete token replaced when the user accepts a completion. */ + prefix: string + /** Path query after `@` or `@"`. */ + query: string + /** Whether the user opened a quoted path. */ + quoted: boolean +} + +/** + * Extract an `@path` or `@"path with spaces` token at the cursor. An `@` + * inside another token, such as an email address, is not a completion trigger. + * @param line - current editor line. + * @param cursorCol - cursor column within that line. + * @returns the active token, or `undefined` outside an `@` token. + */ +export function activeAtToken(line: string, cursorCol: number): ActiveAtToken | undefined { + const beforeCursor = line.slice(0, cursorCol) + const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor) + if (quoted?.[1] !== undefined && quoted[2] !== undefined) { + return { prefix: quoted[1], query: quoted[2], quoted: true } + } + const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor) + if (plain?.[1] === undefined || plain[2] === undefined) return undefined + return { prefix: plain[1], query: plain[2], quoted: false } +} + +/** + * Format a selected path as prompt text. Whitespace uses the quoted + * `@"path"` grammar; a quoted directory keeps that quote open after its + * trailing slash so completion can descend another level. + * @param candidate - selected file or directory. + * @param preserveQuote - retain an explicitly opened quote even when unnecessary. + * @returns the insertion value, or `undefined` for a path the editor grammar cannot represent safely. + */ +export function formatFileMention( + candidate: FileReferenceCandidate, + preserveQuote: boolean, +): string | undefined { + const path = candidate.kind === 'directory' ? `${candidate.path}/` : candidate.path + if (/[\u0000-\u001f\u007f-\u009f"]/u.test(path)) return undefined + const quoted = preserveQuote || /\s/u.test(path) + if (!quoted) return `@${path}` + if (candidate.kind === 'directory') return `@"${path}` + return `@"${path}"` +} diff --git a/packages/context/file-reference/src/index.ts b/packages/context/file-reference/src/index.ts new file mode 100644 index 0000000000..0ffe25116d --- /dev/null +++ b/packages/context/file-reference/src/index.ts @@ -0,0 +1,51 @@ +/** + * File-reference discovery seam shared by host-backed user interfaces. + * + * @module @deepseek-ai/dsh-file-reference + */ + +import { Service } from 'cordis' +import type { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' + +export { activeAtToken, formatFileMention } from './grammar.ts' +export type { ActiveAtToken } from './grammar.ts' + +/** Model guidance for path-only references selected by a user interface. */ +export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' + +/** One path-only completion candidate inside the target session cwd. */ +export interface FileReferenceCandidate { + /** User-facing path accepted by normal prompts and filesystem tools. */ + path: string + /** Directories keep completion open; files finish the mention. */ + kind: 'file' | 'directory' +} + +declare module 'cordis' { + interface Context { + fileReferences: FileReferenceService + } +} + +/** Host capability for cancellable file-reference discovery. */ +export abstract class FileReferenceService extends Service { + constructor(ctx: Context) { + super(ctx, 'fileReferences') + } + + /** + * List file and directory candidates for one agent's working directory. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ + abstract list( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise +} + +export default FileReferenceService diff --git a/packages/context/file-reference/src/invariant.ts b/packages/context/file-reference/src/invariant.ts new file mode 100644 index 0000000000..bc6c93bdc2 --- /dev/null +++ b/packages/context/file-reference/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-file-reference`. + * @module @deepseek-ai/dsh-file-reference/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-file-reference' + +/** Cordis companion plugin name. */ +export const name = 'file-reference-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the interface retains no candidate or lifecycle + * state; concrete providers own their cache and invalidation relationships. + */ +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/context/file-reference/tests/invariant.spec.ts b/packages/context/file-reference/tests/invariant.spec.ts new file mode 100644 index 0000000000..c1fc576a9f --- /dev/null +++ b/packages/context/file-reference/tests/invariant.spec.ts @@ -0,0 +1,12 @@ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as FileReferenceInvariant from '../src/invariant.ts' + +describe('invariant companion', () => { + it('registers the stateless seam under its package name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(FileReferenceInvariant).await()).resolves.toBeDefined() + }) +}) diff --git a/packages/context/file-reference/tsconfig.json b/packages/context/file-reference/tsconfig.json new file mode 100644 index 0000000000..502dd13b89 --- /dev/null +++ b/packages/context/file-reference/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 224e8300ab..d35e8bd9ff 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -260,6 +260,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'fileReferences', + summary: 'Host capability for cancellable file-reference discovery.', + methods: [ + { + signature: 'abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise', + jsDoc: '/**\n * List file and directory candidates for one agent\'s working directory.\n * @param agent - target agent whose session cwd bounds discovery.\n * @param query - path text following `@` or `@"`.\n * @param signal - caller cancellation.\n * @returns deterministic path-only candidates.\n */', + }, + ], + }, { key: 'fs', summary: 'Abstract filesystem provider.', @@ -1603,6 +1613,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'FileLocation', declaration: 'export interface FileLocation {\n path: string;\n line?: number;\n}', }, + { + name: 'FileReferenceCandidate', + declaration: 'export interface FileReferenceCandidate {\n path: string;\n kind: \'file\' | \'directory\';\n}', + }, { name: 'FinishReason', declaration: 'export type FinishReason = FinishReasonMap[keyof FinishReasonMap];', diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 05075a9bd4..8a3bd0d36c 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -19,6 +19,10 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, + "./display": { + "types": "./lib/types/display.d.ts", + "default": "./lib/types/display.js" + }, "./src/*": "./src/*", "./package.json": "./package.json", "./surface": { diff --git a/packages/core/session/src/display.ts b/packages/core/session/src/display.ts new file mode 100644 index 0000000000..0a68b83a6f --- /dev/null +++ b/packages/core/session/src/display.ts @@ -0,0 +1,17 @@ +/** + * Browser-safe human projection helpers for durable prompt messages. + * + * @module @deepseek-ai/dsh-session/display + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { PromptMessageData } from './types.ts' + +/** + * Return the human-facing prompt blocks from a durable prompt message. + * @param data - ordinary or steering prompt event data. + * @returns the effective direct prompt, excluding baked prefix context. + */ +export function displayPromptContent(data: PromptMessageData): ContentBlock[] { + return data.envelope?.displayContent ?? data.content +} diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5a6b274b3e..edef069c2a 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path' import { deepFreeze } from '@deepseek-ai/dsh-llm' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' +import type { Message } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' -import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, PromptMessageData, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts' +import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts' import { snapshotJsonValue } from './json.ts' import { SurfaceManager } from './surface.ts' import type { SessionSurface } from './surface.ts' @@ -28,15 +28,7 @@ export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' - -/** - * Return the human-facing prompt blocks from a durable prompt message. - * @param data - ordinary or steering prompt event data. - * @returns the effective direct prompt, excluding baked prefix context. - */ -export function displayPromptContent(data: PromptMessageData): ContentBlock[] { - return data.envelope?.displayContent ?? data.content -} +export { displayPromptContent } from './display.ts' /** * Find the latest closed message-triggered turn, excluding injection and diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index eb06e14d2d..fd638159d1 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.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 -README.md: 43ad70fa8b865b0b80496bbb67013f24e9e3a33f -README.zh.md: cc95a7512fb872add816bf0456a93dfcf7b84c10 +# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md +README.md: 36584c44feabdfa3da5dd600b630d97f6919c908 +README.zh.md: 4bf9b7151ad4e21c58a84f4aec82d01122ddbfc6 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 43ad70fa8b..36584c44fe 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -14,7 +14,9 @@ The mux stream projects the latest log-backed title as a validated `session/titl Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed` plus `host/session-added` carry committed increments in either arrival order. `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()`. -The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. +The `command.*`, `skill.*`, and `reference.*` domains expose host command, skill, and reference capabilities to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` and `reference.*` resume cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `reference.files` delegates cancellable path discovery to `ctx.fileReferences`; `reference.sessions` delegates candidate ranking and canonical mention creation to `ctx.sessionReferences`. Missing capabilities fail with domain-specific unavailable codes instead of producing an authoritative empty list. `host/commands-changed` is the command-catalog invalidation frame: clients refetch `command.list` instead of diffing. + +`session.prompt` parses canonical session mentions from normalized text blocks and asks `ctx.sessionReferences` to prepare every referenced snapshot before enqueue. Parsing, cancellation, validation, reads, and budget enforcement are one admission transaction: failure enqueues no message, while success passes the returned display content and prompt-prefix contexts together to the agent. ## Carrier layer (`/client` + root) @@ -22,11 +24,11 @@ The `command.*` and `skill.*` domains expose the host command registry and skill ## Model Experience -None, as the package defines the client↔host wire contract and carriers; nothing here reaches a model request. +Indirectly, through `@deepseek-ai/dsh-session-reference`, which prepares canonical session mentions before `session.prompt` enqueues the message. #### KV Cache effect -None; this package neither assembles nor sends a provider request. +Candidate RPCs add no tokens. A successful referenced prompt adds only the context prepared by `ctx.sessionReferences`; failure leaves target history unchanged. ## Known Limitations and Deferred Work diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cc95a7512f..4bf9b7151a 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -14,7 +14,9 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白——惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 +`command.*`、`skill.*` 与 `reference.*` 领域向客户端暴露宿主的命令、skill(技能)和引用功能。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 与 `reference.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`reference.files` 把可取消的路径发现委托给 `ctx.fileReferences`;`reference.sessions` 把候选排序和规范提及标记的创建委托给 `ctx.sessionReferences`。缺少功能时会以对应领域的 unavailable 错误码失败,而不是产生一个看似权威的空列表。`host/commands-changed` 是命令目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 + +`session.prompt` 从规范化文本块中解析规范会话提及标记,并要求 `ctx.sessionReferences` 在消息入队前准备每个被引用的快照。解析、取消、校验、读取和预算约束共同构成一个准入事务:失败时不会有消息入队;成功时则把返回的显示内容与提示词前缀上下文一起传给 agent。 ## 载体层(`/client` + 根路径) @@ -22,11 +24,11 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 模型体验 -无。该包定义客户端与宿主间的协议契约和载体,其中没有任何内容会进入模型请求。 +间接影响模型体验:`@deepseek-ai/dsh-session-reference` 会在 `session.prompt` 将消息入队前准备规范会话提及标记。 #### KV 缓存影响 -无;该包既不组装也不发送提供方请求。 +候选 RPC 不会增加 token。引用提示词成功后,只会增加 `ctx.sessionReferences` 准备的上下文;失败则不会改变目标历史。 ## 已知限制与延期工作 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 0c7107a1f9..1a0f2f04ed 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -43,9 +43,11 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f81f5c9be8..fa61a3fa6a 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -10,7 +10,13 @@ import type { Context } from 'cordis' import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-file-reference' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import { + formatSessionReferenceMention, + parseSessionReferenceText, + type SessionReferenceInput, +} from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { @@ -79,6 +85,21 @@ function err(request: RpcRequest, error: RpcError): RpcResponse { return { rpcId: request.rpcId, result: { ok: false, error } } } +/** Normalize every text block while preserving non-text blocks verbatim. */ +function parseReferencedContent(content: readonly ContentBlock[]): { + content: ContentBlock[] + references: SessionReferenceInput[] +} { + const references: SessionReferenceInput[] = [] + const normalized = content.map((block): ContentBlock => { + if (block.type !== 'text') return block + const parsed = parseSessionReferenceText(block.text) + references.push(...parsed.references) + return { ...block, text: parsed.text } + }) + return { content: normalized, references } +} + /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */ class FrameQueue { private buffer: F[] = [] @@ -645,16 +666,61 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { events: entries, hasMore: page.hasMore }) }, - async prompt(request) { + async prompt(request, signal) { const { sessionId, mode, content } = request.payload const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) const agent = found.agent + let parsed: ReturnType + try { + parsed = parseReferencedContent(content) + } catch (error: unknown) { + return err(request, { + code: 'reference-invalid', + message: 'invalid session reference', + details: { reason: String(error) }, + }) + } + let acceptedContent = parsed.content + let contexts: NonNullable[1]>['contexts'] = [] + if (parsed.references.length > 0) { + const sessionReferences = ctx.get('sessionReferences') + if (sessionReferences === undefined) { + return err(request, { + code: 'reference-unavailable', + message: 'session reference capability unavailable', + details: { kind: 'session' }, + }) + } + try { + const prepared = await sessionReferences.prepare( + agent, + acceptedContent, + parsed.references, + signal, + ) + acceptedContent = prepared.content + contexts = prepared.contexts + } catch (error: unknown) { + if (signal?.aborted === true) { + return err(request, { + code: 'cancelled', + message: 'session reference preparation was aborted', + details: {}, + }) + } + return err(request, { + code: 'reference-failed', + message: 'session reference preparation failed', + details: { reason: String(error) }, + }) + } + } // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { - if (mode === 'steer') agent.steer(content, { source }) - else agent.followup(content, { source }) + if (mode === 'steer') agent.steer(acceptedContent, { source, contexts }) + else agent.followup(acceptedContent, { source, contexts }) } catch (error: unknown) { // A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) @@ -885,6 +951,85 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + references: { + async files(request, signal) { + const { sessionId, query } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const fileReferences = ctx.get('fileReferences') + if (fileReferences === undefined) { + return err(request, { + code: 'reference-unavailable', + message: 'file reference capability unavailable', + details: { kind: 'file' }, + }) + } + const effectiveSignal = signal ?? new AbortController().signal + try { + return ok(request, { + items: await fileReferences.list(found.agent, query, effectiveSignal), + }) + } catch (error: unknown) { + if (effectiveSignal.aborted) { + return err(request, { + code: 'cancelled', + message: 'file reference listing was aborted', + details: {}, + }) + } + return err(request, { + code: 'reference-failed', + message: 'file reference listing failed', + details: { reason: String(error) }, + }) + } + }, + + async sessions(request, signal) { + const { sessionId, query } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + const sessionReferences = ctx.get('sessionReferences') + if (sessionReferences === undefined) { + return err(request, { + code: 'reference-unavailable', + message: 'session reference capability unavailable', + details: { kind: 'session' }, + }) + } + try { + const candidates = await sessionReferences.listCandidates( + found.agent, + query, + undefined, + signal, + ) + return ok(request, { + items: candidates.map(candidate => ({ + ...candidate, + mention: formatSessionReferenceMention({ + sessionId: candidate.sessionId, + label: candidate.label, + }), + })), + }) + } catch (error: unknown) { + if (signal?.aborted === true) { + return err(request, { + code: 'cancelled', + message: 'session reference listing was aborted', + details: {}, + }) + } + return err(request, { + code: 'reference-failed', + message: 'session reference listing failed', + details: { reason: String(error) }, + }) + } + }, + }, + events: { mux(_request, signal) { const queue = new FrameQueue>() diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 537b2744ef..b4ff02a910 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -9,6 +9,7 @@ import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' import type { SkillsApi } from './skills.ts' +import type { ReferencesApi } from './references.ts' import type { EventsApi } from './events.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' @@ -19,6 +20,7 @@ export interface ApiProxy { workspace: WorkspaceApi commands: CommandsApi skills: SkillsApi + references: ReferencesApi events: EventsApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise @@ -30,6 +32,7 @@ export type { HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' +export type { FileReferenceItem, ReferencesApi, SessionReferenceItem } from './references.ts' export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/api/references.schema.ts b/packages/host/apiproxy/src/api/references.schema.ts new file mode 100644 index 0000000000..59bee90e09 --- /dev/null +++ b/packages/host/apiproxy/src/api/references.schema.ts @@ -0,0 +1,42 @@ +/** + * Reference-domain wire schemas. + * + * @module @deepseek-ai/dsh-host-apiproxy/api/references.schema + */ + +import { z } from 'zod' +import type { RequestPayload, ResponseValue } from './rpc-map.ts' +import type { Wire } from './rpc.schema.ts' +import { sessionIdSchema } from './sessions.schema.ts' + +const referenceRequestSchema = z.object({ + sessionId: sessionIdSchema, + query: z.string(), +}) + +/** reference.files request payload. */ +export const referenceFilesRequestSchema = referenceRequestSchema satisfies + z.ZodType>> + +/** reference.files response value. */ +export const referenceFilesValueSchema = z.object({ + items: z.array(z.object({ + path: z.string(), + kind: z.union([z.literal('file'), z.literal('directory')]), + })), +}) satisfies z.ZodType>> + +/** reference.sessions request payload. */ +export const referenceSessionsRequestSchema = referenceRequestSchema satisfies + z.ZodType>> + +/** reference.sessions response value. */ +export const referenceSessionsValueSchema = z.object({ + items: z.array(z.object({ + sessionId: sessionIdSchema, + label: z.string(), + cwd: z.string().optional(), + createdAt: z.number(), + mention: z.string(), + })), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/references.ts b/packages/host/apiproxy/src/api/references.ts new file mode 100644 index 0000000000..13471f8d46 --- /dev/null +++ b/packages/host/apiproxy/src/api/references.ts @@ -0,0 +1,39 @@ +/** + * Reference autocomplete domain contract. + * + * @module @deepseek-ai/dsh-host-apiproxy/api/references + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RpcRequest, RpcResponse } from './rpc.ts' + +/** One file or directory candidate inside the target session workspace. */ +export interface FileReferenceItem { + path: string + kind: 'file' | 'directory' +} + +/** One metadata-only cross-session reference candidate. */ +export interface SessionReferenceItem { + sessionId: SessionId + label: string + cwd?: string + createdAt: number + /** Canonical opaque mention serialized into the prompt draft. */ + mention: string +} + +/** Host-backed file and session reference discovery. */ +export interface ReferencesApi { + /** List path candidates using the target agent's workspace boundary. */ + files( + request: RpcRequest<{ sessionId: SessionId; query: string }>, + signal?: AbortSignal, + ): Promise> + + /** List cross-session candidates excluding the target session itself. */ + sessions( + request: RpcRequest<{ sessionId: SessionId; query: string }>, + signal?: AbortSignal, + ): Promise> +} diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index abe992584c..5b41c35a19 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -9,6 +9,7 @@ import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { CommandsApi } from './commands.ts' import type { SkillsApi } from './skills.ts' +import type { ReferencesApi } from './references.ts' import type { RpcResponse } from './rpc.ts' /** @@ -30,6 +31,8 @@ export interface RpcMethodMap { 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] + 'reference.files': ReferencesApi['files'] + 'reference.sessions': ReferencesApi['sessions'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index d83ae2ce98..653c699c8f 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -42,6 +42,9 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), + z.object({ code: z.literal('reference-unavailable'), message: z.string(), details: z.object({ kind: z.union([z.literal('file'), z.literal('session')]) }) }), + z.object({ code: z.literal('reference-invalid'), message: z.string(), details: z.object({ reason: z.string() }) }), + z.object({ code: z.literal('reference-failed'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index ad06c42fbe..7acd0e0bed 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -39,6 +39,9 @@ export interface RpcErrorDetailsMap { 'workspace-name-conflict': { name: string } 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } 'agent-busy': { reason: string } + 'reference-unavailable': { kind: 'file' | 'session' } + 'reference-invalid': { reason: string } + 'reference-failed': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 2552b5d5a3..2b3eb49fec 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -81,8 +81,15 @@ export interface SessionsApi { history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): Promise> - /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */ - prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): + /** + * Sends a message. Canonical session mentions are normalized and their + * snapshots are prepared atomically before enqueue. mode maps 1:1 — + * queue→followup, steer→steer. + */ + prompt( + request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>, + signal?: AbortSignal, + ): Promise> /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0424ba7a4f..998480b806 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -29,6 +29,10 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' +import { + referenceFilesValueSchema, + referenceSessionsValueSchema, +} from '../api/references.schema.ts' /** * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary @@ -69,6 +73,10 @@ export interface IApiClient { skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } + references: { + files(payload: RequestPayload<'reference.files'>, signal?: AbortSignal): Promise>> + sessions(payload: RequestPayload<'reference.sessions'>, signal?: AbortSignal): Promise>> + } events: { mux(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> host(payload: Parameters[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable> @@ -95,6 +103,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), } + readonly references: IApiClient['references'] = { + files: (payload, signal) => this.callUnary('reference.files', payload, signal), + sessions: (payload, signal) => this.callUnary('reference.sessions', payload, signal), + } + readonly events: IApiClient['events'] = { mux: (payload, signal, onOpen) => this.openMux(payload, signal, onOpen), host: (payload, signal, onOpen) => this.openHost(payload, signal, onOpen), diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index b79980d63e..b205cce054 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -30,6 +30,10 @@ import { } from '../api/workspace.schema.ts' import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' +import { + referenceFilesRequestSchema, + referenceSessionsRequestSchema, +} from '../api/references.schema.ts' /** * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a @@ -38,7 +42,7 @@ import { skillListRequestSchema } from '../api/skills.schema.ts' * Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation * documented on Wire); the dispatch point carries the one Wire→exact cast. * Every invoke receives the carrier Request's signal; methods whose contract - * declares a signal parameter (command.execute) forward it, the rest ignore it. + * declares a signal parameter forwards it, the rest ignore it. */ type UnaryRoutes = { [K in keyof RpcMethodMap]: { @@ -51,7 +55,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) }, 'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) }, 'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) }, - 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, + 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r, signal) => api.sessions.prompt(r, signal) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, @@ -61,6 +65,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, + 'reference.files': { schema: referenceFilesRequestSchema, invoke: (api, r, signal) => api.references.files(r, signal) }, + 'reference.sessions': { schema: referenceSessionsRequestSchema, invoke: (api, r, signal) => api.references.sessions(r, signal) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 8a63c3de32..abb2bb0ab6 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -58,6 +58,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly host: ApiProxy['host'] readonly commands: ApiProxy['commands'] readonly skills: ApiProxy['skills'] + readonly references: ApiProxy['references'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] @@ -75,6 +76,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.host = api.host this.commands = api.commands this.skills = api.skills + this.references = api.references this.events = api.events // createApiProxy returns closures (no `this` capture); bind only satisfies // the unbound-method lint without changing behavior. diff --git a/packages/host/apiproxy/tests/api-proxy-references.spec.ts b/packages/host/apiproxy/tests/api-proxy-references.spec.ts new file mode 100644 index 0000000000..972e9c6938 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-references.spec.ts @@ -0,0 +1,214 @@ +/** + * Reference RPC coverage over the real ApiProxy: addressed Host discovery, + * canonical session mentions, atomic snapshot preparation before enqueue, + * and error/cancellation behavior. + */ +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' +import { RpcId } from '../src/api/rpc.ts' +import { createApiProxy } from '../src/api-proxy.ts' + +const DEFAULTS = { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' } +let nextRpc = 1 + +function request

(payload: P): RpcRequest

{ + return { rpcId: RpcId(`reference-${String(nextRpc++)}`), payload } +} + +function expectOk(response: RpcResponse): T { + expect(response.result.ok).toBe(true) + if (!response.result.ok) throw new Error('unreachable') + return response.result.value +} + +function expectErr(response: RpcResponse): { code: string; message: string } { + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('unreachable') + return response.result.error +} + +async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + ctx.provide('workspace', { list: () => [] } as never) + return ctx +} + +function stubAgent(ctx: Context) { + const session = ctx.sessions.create(undefined, { meta: { cwd: '/project' } }) + const followup = vi.fn() + const steer = vi.fn() + const agent = { + id: session.id, + session, + status: 'idle', + ctx, + followup, + steer, + cancel: vi.fn(), + } as unknown as Agent & { + followup: typeof followup + steer: typeof steer + } + ctx.agents.register(agent) + return agent +} + +describe('reference discovery', () => { + it('addresses the target agent and returns file candidates unchanged', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const list = vi.fn(() => Promise.resolve([ + { path: 'src', kind: 'directory' as const }, + { path: 'src/index.ts', kind: 'file' as const }, + ])) + ctx.provide('fileReferences', { list } as never) + const api = createApiProxy(ctx, DEFAULTS) + const signal = new AbortController().signal + const value = expectOk(await api.references.files( + request({ sessionId: agent.id, query: 'sr' }), + signal, + )) + expect(value.items).toEqual([ + { path: 'src', kind: 'directory' }, + { path: 'src/index.ts', kind: 'file' }, + ]) + expect(list).toHaveBeenCalledWith(agent, 'sr', signal) + }) + + it('formats metadata candidates as opaque canonical mentions', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const source = 'source-session' as SessionId + const listCandidates = vi.fn(() => Promise.resolve([{ + sessionId: source, + label: 'Research]', + cwd: '/project', + createdAt: 42, + }])) + ctx.provide('sessionReferences', { listCandidates } as never) + const api = createApiProxy(ctx, DEFAULTS) + const value = expectOk(await api.references.sessions( + request({ sessionId: agent.id, query: 'res' }), + new AbortController().signal, + )) + expect(value.items).toEqual([{ + sessionId: source, + label: 'Research]', + cwd: '/project', + createdAt: 42, + mention: formatSessionReferenceMention({ sessionId: source, label: 'Research]' }), + }]) + expect(listCandidates).toHaveBeenCalledWith(agent, 'res', undefined, expect.any(AbortSignal)) + }) + + it('fails explicitly when a reference capability is not composed', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const api = createApiProxy(ctx, DEFAULTS) + expect(expectErr(await api.references.files( + request({ sessionId: agent.id, query: '' }), + )).code).toBe('reference-unavailable') + expect(expectErr(await api.references.sessions( + request({ sessionId: agent.id, query: '' }), + )).code).toBe('reference-unavailable') + }) +}) + +describe('referenced prompt preparation', () => { + it('normalizes the visible mention and waits for all context preparation before enqueue', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const source = 'source-session' as SessionId + const mention = formatSessionReferenceMention({ sessionId: source, label: 'Research' }) + let finish!: () => void + const context = { + source: { kind: 'plugin' as const, plugin: 'session-reference' }, + content: [{ type: 'text' as const, text: 'snapshot' }], + placement: 'prompt-prefix' as const, + meta: { + kind: 'session-reference', + version: 1, + references: [{ sessionId: source, label: 'Research' }], + }, + } + const prepare = vi.fn(() => new Promise<{ + content: { type: 'text'; text: string }[] + contexts: typeof context[] + }>((resolve) => { + finish = () => { + resolve({ + content: [{ type: 'text', text: 'compare @Research now' }], + contexts: [context], + }) + } + })) + ctx.provide('sessionReferences', { prepare } as never) + const api = createApiProxy(ctx, DEFAULTS) + const signal = new AbortController().signal + const pending = api.sessions.prompt(request({ + sessionId: agent.id, + content: [{ type: 'text' as const, text: `compare ${mention} now` }], + mode: 'queue' as const, + }), signal) + await Promise.resolve() + expect(agent.followup).not.toHaveBeenCalled() + expect(prepare).toHaveBeenCalledWith( + agent, + [{ type: 'text', text: 'compare @Research now' }], + [{ sessionId: source, label: 'Research' }], + signal, + ) + finish() + expect(expectOk(await pending)).toEqual({ accepted: true }) + expect(agent.followup).toHaveBeenCalledTimes(1) + expect(agent.followup.mock.calls[0]?.[0]).toEqual([ + { type: 'text', text: 'compare @Research now' }, + ]) + expect(agent.followup.mock.calls[0]?.[1]?.source?.kind).toBe('user') + expect(agent.followup.mock.calls[0]?.[1]?.contexts).toEqual([context]) + }) + + it('rejects malformed mentions and preparation failures without enqueueing any prompt', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const prepare = vi.fn(() => Promise.reject(new Error('snapshot unavailable'))) + ctx.provide('sessionReferences', { prepare } as never) + const api = createApiProxy(ctx, DEFAULTS) + + const malformed = await api.sessions.prompt(request({ + sessionId: agent.id, + content: [{ type: 'text' as const, text: '@[bad](dsh-session:not-canonical)' }], + mode: 'queue' as const, + })) + expect(expectErr(malformed).code).toBe('reference-invalid') + expect(prepare).not.toHaveBeenCalled() + expect(agent.followup).not.toHaveBeenCalled() + + const mention = formatSessionReferenceMention({ + sessionId: 'source-session' as SessionId, + label: 'Research', + }) + const failed = await api.sessions.prompt(request({ + sessionId: agent.id, + content: [{ type: 'text' as const, text: mention }], + mode: 'queue' as const, + })) + expect(expectErr(failed).code).toBe('reference-failed') + expect(agent.followup).not.toHaveBeenCalled() + expect(agent.steer).not.toHaveBeenCalled() + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index a9a5eac9ba..a4b5ce4c3b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -22,6 +22,7 @@ function scriptedApi(overrides: { host?: Partial commands?: Partial skills?: Partial + references?: Partial events?: Partial respond?: ApiProxy['respond'] } = {}): ApiProxy { @@ -48,6 +49,11 @@ function scriptedApi(overrides: { ...overrides.commands, }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, + references: { + files: r => ok(r, { items: [] }), + sessions: r => ok(r, { items: [] }), + ...overrides.references, + }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), } @@ -89,6 +95,39 @@ describe('unary round trip', () => { expect(appended.result.ok).toBe(true) }) + it('routes file and session reference candidates through their wire schemas', async () => { + const c = client(scriptedApi({ + references: { + files: r => ok(r, { items: [{ path: 'src/index.ts', kind: 'file' as const }] }), + sessions: r => ok(r, { + items: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 42, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], + }), + }, + })) + await expect(c.references.files({ sessionId: sid('target'), query: 'src' })).resolves.toMatchObject({ + result: { ok: true, value: { items: [{ path: 'src/index.ts', kind: 'file' }] } }, + }) + await expect(c.references.sessions({ sessionId: sid('target'), query: 'res' })).resolves.toMatchObject({ + result: { + ok: true, + value: { + items: [{ + sessionId: 'source', + label: 'Research', + cwd: '/project', + createdAt: 42, + }], + }, + }, + }) + }) + it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ sessions: { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index e8d65d2a62..187944f8d7 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -89,6 +89,14 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits' }] } } } }, }, + references: { + async files(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } + }, + async sessions(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } + }, + }, events: { mux: (_request, signal) => stream(muxFrames, signal), host: (_request, signal) => stream(hostFrames, signal), diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index f5aabb1cf8..97943e335a 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../core/agent" }, + { + "path": "../../context/file-reference" + }, { "path": "../../core/session" }, @@ -32,6 +35,9 @@ { "path": "../../session-persistence/session-persistence" }, + { + "path": "../../context/session-reference" + }, { "path": "../../session-title/session-title" }, diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 3242f5a2a3..afbda036b1 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -34,6 +34,7 @@ "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", "@deepseek-ai/dsh-goal": "^0.0.1", + "@deepseek-ai/dsh-file-reference-local": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -66,6 +67,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index de0ef02f7e..1e29c678ef 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -169,22 +169,21 @@ import { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, + FILE_REFERENCE_PROMPT, formatFileMention, WorkspaceFileSearch, -} from './file-autocomplete.ts' +} from '@deepseek-ai/dsh-file-reference-local' export { DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, DEFAULT_FILE_SEARCH_MAX_ENTRIES, DEFAULT_FILE_SEARCH_MAX_RESULTS, -} from './file-autocomplete.ts' + FILE_REFERENCE_PROMPT, +} from '@deepseek-ai/dsh-file-reference-local' export const name = 'ui-tui' export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] -/** Model guidance for path-only file references selected through the TUI. */ -export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' - /** Interaction and presentation settings for the pi-tui terminal mode. */ export interface TuiConfig { /** Render model reasoning blocks. */ diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c7af95061a..8bae946199 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -26,7 +26,7 @@ import { type TuiOverlaySession, type TuiRuntime, } from '../src/index.ts' -import { WorkspaceFileSearch } from '../src/file-autocomplete.ts' +import { WorkspaceFileSearch } from '@deepseek-ai/dsh-file-reference-local' import { appendAssistant, appendUser, diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index 3560d6bc9d..5d8dd39387 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../context/file-reference-local" + }, { "path": "../../core/session" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e7b31fe0..9b77726d36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,9 @@ importers: '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../packages/client/ui-question + '@deepseek-ai/dsh-client-ui-reference': + specifier: workspace:^ + version: link:../../packages/client/ui-reference '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../packages/client/ui-settings @@ -164,9 +167,6 @@ importers: '@deepseek-ai/dsh-client-ui-slash': specifier: workspace:^ version: link:../../packages/client/ui-slash - '@deepseek-ai/dsh-client-ui-subagent': - specifier: workspace:^ - version: link:../../packages/client/ui-subagent '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../packages/client/ui-theme @@ -185,6 +185,12 @@ importers: '@deepseek-ai/dsh-compact-basic': specifier: workspace:^ version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../packages/context/file-reference + '@deepseek-ai/dsh-file-reference-local': + specifier: workspace:^ + version: link:../../packages/context/file-reference-local '@deepseek-ai/dsh-frontend': specifier: workspace:^ version: link:../web @@ -221,6 +227,12 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../packages/session-query/session-query-sqlite + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../packages/context/session-reference '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title @@ -1072,6 +1084,27 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-reference: + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../ui-slash + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-settings: dependencies: clsx: @@ -1197,6 +1230,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1222,24 +1258,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/client/ui-subagent: - devDependencies: - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-slash': - specifier: workspace:^ - version: link:../ui-slash - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/client/ui-theme: dependencies: clsx: @@ -1514,6 +1532,43 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/context/file-reference: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/context/file-reference-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../file-reference + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/context/session-reference: dependencies: schemastery: @@ -2550,6 +2605,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../ui/commands + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -2559,6 +2617,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session-title/session-title @@ -4296,6 +4357,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../commands + '@deepseek-ai/dsh-file-reference-local': + specifier: workspace:^ + version: link:../../context/file-reference-local '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 693f0aab60..6a663f865e 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -221,6 +221,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md', DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md', DomainSpec: 'domain declaration contract is owned by packages/storage/storage-domain/README.md', + FileReferenceCandidate: 'service-local discovery result is owned by packages/context/file-reference/README.md', StorageBackend: 'backend contract is owned by packages/storage/storage/src/backend.ts', StorageForms: 'merge-extensible form map is owned by packages/storage/storage/src/index.ts', InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 990acd60c5..b7203bdffe 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -169,13 +169,22 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['session-reference', 'tool-session-query'], note: '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.', }, + { + key: 'fileReferences', + pkg: 'file-reference', + title: 'Workspace file-reference discovery', + mode: 'seam', + implementations: ['file-reference-local'], + consumers: ['apiproxy'], + note: 'The local provider owns one invalidated path index per agent; Host RPC projects its cancellable path candidates to browser reference sources.', + }, { key: 'sessionReferences', pkg: 'session-reference', title: 'Cross-session snapshot preparation', mode: 'core', - consumers: ['tui'], - note: 'Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax.', + consumers: ['apiproxy', 'tui'], + note: 'Projects bounded current-surface conversation snapshots into durable untrusted message context; Host and TUI adapters own mention syntax.', }, { key: 'sessionTitle', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0aa5ae6c34..9ae2d223ed 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -57,6 +57,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-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-reference': { kind: 'indirect', reason: 'The browser source serializes references whose model rendering is owned by file-reference-local and session-reference.' }, 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-workspace': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, @@ -71,7 +72,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, - 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' }, + 'packages/context/file-reference': { kind: 'indirect', reason: 'The interface and grammar delegate model guidance to concrete file-reference providers.' }, + 'packages/host/apiproxy': { kind: 'indirect', reason: 'The gateway delegates prepared cross-session model context to dsh-session-reference.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index fcfe6f6e90..49da491450 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -41,6 +41,8 @@ "@deepseek-ai/dsh-session/invariant": ["./packages/core/session/src/invariant.ts"], "@deepseek-ai/dsh-session/types": ["./packages/core/session/src/types.ts"], "@deepseek-ai/dsh-session/surface": ["./packages/core/session/src/surface.ts"], + "@deepseek-ai/dsh-session/display": ["./packages/core/session/src/display.ts"], + "@deepseek-ai/dsh-file-reference/grammar": ["./packages/context/file-reference/src/grammar.ts"], "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"], "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], @@ -114,7 +116,7 @@ "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], - "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], + "@deepseek-ai/dsh-client-ui-reference": ["./packages/client/ui-reference/src"], "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], "@deepseek-ai/dsh-client-ui-workspace": ["./packages/client/ui-workspace/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 0da6e76918..33fd294cc8 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -38,7 +38,7 @@ { "path": "./packages/client/ui-slash" }, { "path": "./packages/client/ui-command" }, { "path": "./packages/client/ui-skill" }, - { "path": "./packages/client/ui-subagent" }, + { "path": "./packages/client/ui-reference" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 2c368e53ed..7b0716af37 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -75,6 +75,8 @@ { "path": "./packages/goal/goal-session" }, { "path": "./packages/goal/command-goal" }, { "path": "./packages/context/time-context" }, + { "path": "./packages/context/file-reference" }, + { "path": "./packages/context/file-reference-local" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/vitest.config.ts b/vitest.config.ts index 33228e21ec..adb3f5a855 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -128,7 +128,7 @@ export default defineConfig({ 'packages/client/ui-command/src/index.ts', 'packages/client/ui-skill/src/index.ts', 'packages/client/ui-slash/src/index.ts', - 'packages/client/ui-subagent/src/index.ts', + 'packages/client/ui-reference/src/index.ts', 'packages/client/ui-command/src/client/popup.ts', 'packages/client/ui-command/src/client/directory.ts', 'packages/client/ui-command/src/client/service.ts', From 12f69144a426386c59aeeae2091979d0fdce1a73 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 28 Jul 2026 15:16:36 +0800 Subject: [PATCH 002/137] fix(web): harden session reference submission --- ...-web-file-and-session-references.i18n.yaml | 4 +- ...6-07-27-web-file-and-session-references.md | 4 +- ...7-27-web-file-and-session-references.zh.md | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageItem.tsx | 21 ++++++++-- .../tests/chat-branch-tails.spec.tsx | 16 ++++++++ packages/host/apiproxy/src/api-proxy.ts | 7 ++++ .../tests/api-proxy-references.spec.ts | 39 +++++++++++++++++++ 10 files changed, 90 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml index 0c8290a540..c8a03e6c06 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.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-27-web-file-and-session-references.md -2026-07-27-web-file-and-session-references.md: 71648a6ddbcffc7e700db1b0ce2135bf157e2bab -2026-07-27-web-file-and-session-references.zh.md: 05c0896700ae55c64b94c0b37a00a38127c97f1f +2026-07-27-web-file-and-session-references.md: 98aad4b40a47a27cd66a3bb5a040cf52dcbfbbd5 +2026-07-27-web-file-and-session-references.zh.md: f0ba076a013aa17c26eec95dcf8c5eb66c7b3829 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md index 71648a6ddb..98aad4b40a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -16,7 +16,7 @@ The file capability follows the three-package seam: `@deepseek-ai/dsh-file-refer A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferences.prepare()` before enqueue, then passes the prepared content and contexts in one agent operation. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure enqueue nothing. -The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or RPC failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders each metadata-confirmed session label as a reference chip even when following text is adjacent, plus a compact session-source summary instead of the snapshot JSON baked into model content. +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or RPC failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders adjacent metadata-confirmed session references as separate reference chips and preserves that projection when following text is adjacent without whitespace, plus a compact session-source summary instead of the snapshot JSON baked into model content. ## Reference transaction @@ -42,7 +42,7 @@ File lookup is advisory and cancellable; selection itself performs no read. Sess ## Verification -Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-text reference projection, codec round-trip, Host wire validation, all-or-nothing prompt preparation, and draft retention across serialization and RPC failures. The keyless assembled Web snapshot renders the available reference sections, selects a directory and file, then selects a session reference through the real client composition. +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, Host wire validation, all-or-nothing prompt preparation, and draft retention across serialization and RPC failures. The keyless assembled Web snapshot renders the available reference sections, selects a directory and file, then selects a session reference through the real client composition. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md index 05c0896700..f0ba076a01 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -16,7 +16,7 @@ Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在入队前调用 `ctx.sessionReferences.prepare()`,随后以一次 agent 操作传入准备后的内容和上下文。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会让消息入队。 -输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 RPC 失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把元数据确认的每个会话标签渲染为引用 chip,即使后续文本与标签直接相邻也如此,并显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 RPC 失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把相邻且经元数据确认的会话引用分别渲染为独立的引用 chip,即使后续文本无空白紧邻,也会保持该投影;同时显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 ## 引用事务 @@ -42,7 +42,7 @@ type @ → parallel file/session RPCs → pick path text or canonical session ch ## 验证 -包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻文本条件下的引用投影、codec 无损往返、宿主协议校验、全有或全无的提示词准备,以及在序列化和 RPC 失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择目录、文件和会话引用。 +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、宿主协议校验、全有或全无的提示词准备,以及在序列化和 RPC 失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择目录、文件和会话引用。 ## 后果 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a2c820b67a..b09d64e2c2 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: e57c46ef7153678705770b761ed78912feaa7a04 -README.zh.md: 985886f6963d2cd517c71c211d4979623276ca74 +README.md: 1324ac27eb0e385fee7bcfa78e604752cd0aa108 +README.zh.md: 35a7d65b3e7d9d057c0eed57e5eaaf7f4b09892a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e57c46ef71..1324ac27eb 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ Per-session UI state for selection and the active view lives in the declared cha The composer bar declares session-scoped single seats for `'conversation.input.plan'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats. -Ordinary submission is a transaction between the input machine and its default sink. The composer retains its draft and atomic reference chips while serialization or `session.prompt` is pending, clears them only after Host acceptance, and restores the editable phase unchanged after rejection. Replay distinguishes sourced session-reference context from direct user messages, associates adjacent pairs, projects confirmed labels as reference chips even when following prompt text is adjacent, and adds a compact `引用会话` source summary below the direct user text instead of exposing the prepared snapshot JSON. +Ordinary submission is a transaction between the input machine and its default sink. The composer retains its draft and atomic reference chips while serialization or `session.prompt` is pending, clears them only after Host acceptance, and restores the editable phase unchanged after rejection. Replay distinguishes sourced session-reference context from direct user messages, associates adjacent pairs, projects adjacent confirmed session references as separate reference chips, preserves that projection when following prompt text is adjacent without whitespace, and adds a compact `引用会话` source summary below the direct user text instead of exposing the prepared snapshot JSON. `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 985886f696..35a7d65b3e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -18,7 +18,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 输入栏为 `'conversation.input.plan'` 和 `'conversation.input.model'` 声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送/停止按钮之前。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 -普通提交是输入状态机与默认 sink 之间的一项事务。在序列化或 `session.prompt` 等待完成期间,输入框会保留草稿和原子引用 chip;只有宿主接受后才会将它们清除,拒绝后则原样恢复可编辑阶段。回放会区分带来源信息的会话引用上下文与直接用户消息,并关联相邻的消息对;即使后续提示词文本与标签直接相邻,也会把已确认的标签投影为引用 chip,并在直接用户文本下方添加精简的 `引用会话` 来源摘要,而不会暴露准备好的快照 JSON。 +普通提交是输入状态机与默认 sink 之间的一项事务。在序列化或 `session.prompt` 等待完成期间,输入框会保留草稿和原子引用 chip;只有宿主接受后才会将它们清除,拒绝后则原样恢复可编辑阶段。回放会区分带来源信息的会话引用上下文与直接用户消息,并关联相邻的消息对;相邻的已确认会话引用会分别投影为独立的引用 chip,即使后续提示词文本无空白紧邻,也会保持该投影;同时在直接用户文本下方添加精简的 `引用会话` 来源摘要,而不会暴露准备好的快照 JSON。 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 1c9b8eead3..f598682614 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -79,9 +79,9 @@ function projectUserText(text: string, sessionLabels: readonly string[] = []): R .filter(label => label.length > 0) .sort((left, right) => right.length - left.length) .map(label => label.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')) - const sessionPattern = exactSessions.length === 0 ? '' : `@(?:${exactSessions.join('|')})|` + const sessionPattern = exactSessions.length === 0 ? '(?!)' : `@(?:${exactSessions.join('|')})` const re = new RegExp( - `([^<]+)|(^|\\s)(${sessionPattern}[/@][\\w-]+(?=\\s|$))`, + `([^<]+)|(^|\\s)(?:((?:${sessionPattern})+)|([/@][\\w-]+(?=\\s|$)))`, 'gu', ) const parts: ReactNode[] = [] @@ -90,8 +90,23 @@ function projectUserText(text: string, sessionLabels: readonly string[] = []): R while ((m = re.exec(text)) !== null) { const legacy = m[1] !== undefined const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0) - const label = legacy ? `/${m[1]}` : m[3] ?? '' if (tokenStart > cursor) parts.push() + const sessionRun = m[3] + if (sessionRun !== undefined) { + const sessionRe = new RegExp(sessionPattern, 'gu') + let session: RegExpExecArray | null + while ((session = sessionRe.exec(sessionRun)) !== null) { + const start = tokenStart + session.index + parts.push( + + {session[0]} + , + ) + } + cursor = tokenStart + sessionRun.length + continue + } + const label = legacy ? `/${m[1]}` : m[4] ?? '' parts.push( {label} diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 76b47a9ab2..84999f48d0 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -50,6 +50,22 @@ describe('MessageItem arms', () => { expect(view.container.querySelector('[data-ref-chip="reference"]')?.textContent).toBe('@你好') }) + it('styles adjacent confirmed session labels as separate reference chips', () => { + const view = render( + , + ) + expect(view.container.textContent).toContain('@Research notes@Research继续') + expect([...view.container.querySelectorAll('[data-ref-chip="reference"]')] + .map(chip => chip.textContent)).toEqual(['@Research notes', '@Research']) + }) + it('user bubbles expose copy / branch / edit actions; copy writes the text', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 7dd735f910..5d042ec113 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -967,6 +967,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } } + if (signal?.aborted === true) { + return err(request, { + code: 'cancelled', + message: 'prompt submission was aborted', + details: {}, + }) + } // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } try { diff --git a/packages/host/apiproxy/tests/api-proxy-references.spec.ts b/packages/host/apiproxy/tests/api-proxy-references.spec.ts index b9ae0a4266..6a6ca3bf94 100644 --- a/packages/host/apiproxy/tests/api-proxy-references.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-references.spec.ts @@ -242,6 +242,45 @@ describe('referenced prompt preparation', () => { ) }) + it.each(['queue', 'steer'] as const)( + 'does not deliver a %s prompt when preparation resolves after cancellation', + async (mode) => { + const ctx = await harness() + const agent = stubAgent(ctx) + const controller = new AbortController() + const source = 'source-session' as SessionId + const mention = formatSessionReferenceMention({ sessionId: source, label: 'Research' }) + ctx.provide('sessionReferences', { + prepare: async () => { + controller.abort() + return { + content: [{ type: 'text' as const, text: '@Research' }], + additionalContext: { + source: { + kind: 'session-reference' as const, + version: 1 as const, + references: [{ sessionId: source, label: 'Research' }], + }, + content: [{ type: 'text' as const, text: 'snapshot' }], + }, + } + }, + } as never) + const api = createApiProxy(ctx, DEFAULTS) + + const response = await api.sessions.prompt(request({ + sessionId: agent.id, + content: [{ type: 'text' as const, text: mention }], + mode, + }), controller.signal) + + expect(expectErr(response).code).toBe('cancelled') + expect(agent.followup).not.toHaveBeenCalled() + expect(agent.steer).not.toHaveBeenCalled() + expect(agent.inject).not.toHaveBeenCalled() + }, + ) + it('rejects malformed mentions and preparation failures without enqueueing any prompt', async () => { const ctx = await harness() const agent = stubAgent(ctx) From e3d02f3c10f82c3559e2aceb60a6ddff2971237a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 10:43:35 +0800 Subject: [PATCH 003/137] fix(web): deduplicate reference submission paths --- .../src/client/chat/MessageItem.tsx | 46 +++++++++++-------- .../src/client/input/facade.ts | 25 +++------- packages/host/apiproxy/src/api-proxy.ts | 35 +++++++++----- 3 files changed, 58 insertions(+), 48 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 3c04422981..75e886f5ff 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -81,21 +81,38 @@ function projectUserText(text: string, sessionLabels: readonly string[] = []): R return <>{parts} } +function UserBubble({ + text, + rest, + sessionLabels, + steering, +}: { + text: string + rest: readonly unknown[] + sessionLabels: readonly string[] + steering?: boolean +}): ReactNode { + return ( +

+
+ {steering === true ? 插话 : null} + {projectUserText(text, sessionLabels)} + {rest.map((block, i) => )} +
+ {sessionLabels.length > 0 + ?
引用会话 · {sessionLabels.join(', ')}
+ : null} +
+ ) +} + export const MessageItem = memo(function MessageItem({ node, sessionLabels = [] }: MessageItemProps) { switch (node.kind) { case 'user': { const { text, rest } = contentText(node.content) return (
-
-
- {projectUserText(text, sessionLabels)} - {rest.map((block, i) => )} -
- {sessionLabels.length > 0 - ?
引用会话 · {sessionLabels.join(', ')}
- : null} -
+ -
-
- 插话 - {projectUserText(text, sessionLabels)} - {rest.map((block, i) => )} -
- {sessionLabels.length > 0 - ?
引用会话 · {sessionLabels.join(', ')}
- : null} -
+
) } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 85dc945a77..f984f9ba89 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -348,7 +348,7 @@ export class SessionInputShell implements SessionInput { private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: 'queue' | 'steer'): void { const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.settleDefault(attempt, this.deps.defaultSink(draft.trim(), mode, attempt.signal)) + this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), mode, attempt.signal)) return } const slash = this.deps.slash?.() @@ -368,7 +368,7 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + 1 } out += draft.slice(cursor) - this.settleDefault(attempt, this.deps.defaultSink(out.trim(), mode, attempt.signal)) + this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), mode, attempt.signal)) }, (error: unknown) => { controller.abort() @@ -384,7 +384,7 @@ export class SessionInputShell implements SessionInput { ) } - private settleDefault( + private settleSubmit( attempt: SubmitAttempt, pending: Promise, ): void { @@ -433,21 +433,10 @@ export class SessionInputShell implements SessionInput { /** The submit transaction: claim.submit against the session scope; ok maps from the outcome kind. */ private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void { - Promise.resolve() - .then(() => claim.submit(args, this.deps.actx)) - .then( - (outcome) => { - if (this.dead(attempt)) return - this.run(this.core.dispatch({ - type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome, - })) - }, - (error: unknown) => { - if (this.dead(attempt)) return - const message = error instanceof Error ? error.message : String(error) - this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) - }, - ) + this.settleSubmit( + attempt, + Promise.resolve().then(() => claim.submit(args, this.deps.actx)), + ) } /** Late-settlement guard: superseded attempts and disposed facades drop silently. */ diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8329fae188..ee521e74ce 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { - Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement, + Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement, PromptDecision, } from '@deepseek-ai/dsh-agent' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' @@ -458,6 +458,22 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie } } +async function appendPreparedPromptContext( + matchesPrompt: boolean, + additionalContext: UserMessage, + cleanup: () => void, + next: () => Promise, +): Promise { + if (!matchesPrompt) return next() + cleanup() + const decision = await next() + if (decision.kind !== 'allow') return decision + return { + ...decision, + additionalContexts: [...decision.additionalContexts ?? [], additionalContext], + } +} + /** * Deliver a prepared browser prompt while preserving session-reference * admission ownership under the context-injection contract. @@ -490,16 +506,13 @@ function deliverPrompt( detachSubmit() detachDiscard() } - detachSubmit = ctx.on('agent/prompt-submit', async (subject, submitted, _signal, next) => { - if (subject !== agent || submitted.id !== message.id) return next() - cleanup() - const decision = await next() - if (decision.kind !== 'allow') return decision - return { - ...decision, - additionalContexts: [...decision.additionalContexts ?? [], additionalContext], - } - }, { prepend: true }) + detachSubmit = ctx.on('agent/prompt-submit', (subject, submitted, _signal, next) => + appendPreparedPromptContext( + subject === agent && submitted.id === message.id, + additionalContext, + cleanup, + next, + ), { prepend: true }) detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => { if (subject === agent && messages.some(discarded => discarded.id === message.id)) cleanup() }) From 3a231b122e05e5418a8a9b48a6b8cb7a0b9b9705 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 4 Aug 2026 15:47:18 +0800 Subject: [PATCH 004/137] test(web): cover ui-subagent host half --- packages/client/ui-subagent/tests/browser-plugin.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 648381a8ed..84c4453214 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -13,6 +13,7 @@ import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from '../src/client/SubagentReadOnlyComposer.tsx' import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' const sid = (id: string) => id as SessionId @@ -56,6 +57,10 @@ async function bench() { } describe('apply', () => { + it('exposes a no-op Host half for Loader discovery', () => { + expect(nodeApply).not.toThrow() + }) + it('declares the services it binds', () => { expect(inject).toEqual(['sessions', 'conversation', 'slots', 'locale']) }) From 48d8e2f8f5870fa182db91a460cddacc6c0a0678 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 12:21:44 +0800 Subject: [PATCH 005/137] feat(agent-loop): finalize a cancelled stream's delivered prefix A turn cancelled mid-stream dropped everything the interrupted step had streamed: chunks stayed in the log for replay, but no assistant/message joined the surface, so the next request carried nothing the user had watched stream. Follow-ups after cancel could not connect and forks inherited the gap. Keep the streaming attempt alive across the step's request loop and, when an abort escapes with the attempt uncommitted, finalize its user-visible prefix as the step's ordinary assistant/message citing the logged chunk seqs. BlockAssembler.interruptedBlocks() owns the safe subset next to the max-tokens rule: closed and open text/reasoning blocks with content, in stream order; tool calls, empty blocks, and unknown open blocks drop. Retry clears the attempt first, so an abort after llm/retry finalizes nothing from the reset stream. Tool-phase cancellation and provider failures keep their existing shapes. --- ...cancelled-stream-prefix-finalize.i18n.yaml | 6 + ...-08-10-cancelled-stream-prefix-finalize.md | 37 ++++ ...-10-cancelled-stream-prefix-finalize.zh.md | 37 ++++ docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 21 ++- docs/persistence-catalog.zh.md | 9 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 9 + docs/subsystems/llm-streaming.zh.md | 9 + docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 5 +- docs/subsystems/session.zh.md | 5 +- .../goal-session/session.expected.jsonl | 7 +- .../goal-session/stdout.expected.jsonl | 1 + .../tests/snapshots/cancel/session.jsonl | 5 +- .../snapshots/cancel/stdout.expected.jsonl | 1 + packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 160 ++++++++++++------ packages/core/agent-loop/tests/cancel.spec.ts | 100 ++++++++++- .../core/agent-loop/tests/mock-adapter.ts | 20 ++- packages/core/session/src/types.ts | 5 +- packages/llm/llm/src/assembler.ts | 20 +++ packages/llm/llm/tests/assembler.spec.ts | 38 +++++ 25 files changed, 426 insertions(+), 89 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml new file mode 100644 index 0000000000..9c577a6c74 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.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-cancelled-stream-prefix-finalize.md +2026-08-10-cancelled-stream-prefix-finalize.md: a2201cd17fd89744590bd4024bff80f3e3835e39 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: 08abb5c097bdf3c6c21e0a96873ab4a995e8f3e9 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md new file mode 100644 index 0000000000..a2201cd17f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md @@ -0,0 +1,37 @@ +# Agent Note: Cancelled streams finalize their delivered prefix + +Status: implemented + +English | [中文](2026-08-10-cancelled-stream-prefix-finalize.zh.md) + +## Problem + +A turn cancelled mid-stream used to drop everything the interrupted step had streamed: `assistant/chunk` events stayed in the log for replay, but no `assistant/message` joined the surface, so `deriveMessages()` carried nothing of the interrupted output into the next request. The user had watched the text stream and clients keep rendering it after the abort, yet from the model's perspective that text was never said. A follow-up like "expand on your second point" could not connect, and a fork taken at the cancelled turn inherited a surface missing content its owner had read. This divergence was never a weighed decision — the first agent-loop implementation checked the abort signal inside the chunk loop and threw before the finalize append, and the later surface whitelist froze that shape in. + +The governing principle this violated: what the user can see, the next model request contains. + +## Decision + +`Agent.step()` keeps the current streaming attempt (assembler, logged chunk seqs, provider route) alive across the request loop. When an abort escapes the step while an attempt is uncommitted, `appendInterruptedAssistant` finalizes the attempt's user-visible prefix as the step's ordinary `assistant/message` — `surfaceOp: 'append'`, `sourceEventSeqs` citing exactly the logged chunks — before the abort continues to the `step/end`/`turn/end` teardown. A retry decision clears the attempt first: `llm/retry` resets what clients render, so an abort after it finalizes nothing from the abandoned attempt. + +`BlockAssembler.interruptedBlocks()` owns what is safe to finalize, next to the existing max-tokens truncation rule: closed and open `text`/`reasoning` blocks with non-whitespace content, in stream order. Tool calls are dropped whole — interruption precedes dispatch, so a kept call would demand a fabricated result — as are empty blocks and open blocks of unknown type. When nothing survives, no event is appended and the turn keeps its previous shape: chunks, `step/end`, `turn/end` aborted. + +Cancellation during tool execution is untouched: the tool-call message was already finalized, started calls drain into real results, and undispatched calls keep their synthetic `ABORTED_BEFORE_DISPATCH` pairs. Provider failures (terminal error or aborted finishes) still commit nothing; only turn cancellation finalizes a prefix, because only there did the user watch content that would otherwise vanish from the model's history. + +## Alternatives considered + +**Keep dropping the prefix (status quo).** Safe and simple, but it makes cancel-then-redirect — a high-frequency flow — manufacture a user-visible/model-visible split on every use, and fork inherits the gap. Rejected: the split's cost recurs; the finalize cost is one-time. + +**Project the prefix at request time from the logged chunks.** No new surface event; `deriveMessages()` would assemble chunk prefixes for aborted steps. Rejected: it moves assembly policy into every surface consumer, breaks the "three message-producing event types" surface contract, and makes the derived history depend on non-surface events. + +**Finalize complete tool-call blocks too, with synthetic aborted results.** Preserves more of the model's intent. Rejected: the calls never dispatched and never rendered as tool cards, so parity does not ask for them, and fabricated result pairs add model-visible noise; the max-tokens rule already drops undispatchable calls. + +**Append an explicit interruption marker (`[interrupted by user]` user message).** What Claude Code does; tells the model its answer was cut off rather than complete. Deferred, not rejected: it is a separate model-visible vocabulary decision (source kind, UI rendering, locale strings) stacked on top of this parity fix, and the durable `turn/end aborted` already records the fact for a future projection to use. + +## Consequences + +The surface now contains what the user saw at the moment of cancellation, so post-cancel follow-ups and forks connect. The cancel and goal snapshot fixtures record the finalized prefix event, and the ACP bridge forwards it as a final `agent_message_chunk` update after the cancelled stop reason. An interrupted step's `assistant/message` can now carry a mid-sentence prefix; consumers reading the aborted `turn/end` can classify it. Terminal provider errors keep the old behavior — their streamed prefix still vanishes from the surface — an asymmetry deliberately left for a follow-up decision because error turns end without the user choosing to stop. + +## Testing + +`packages/core/agent-loop/tests/cancel.spec.ts` pins mid-stream finalize (content, cited seqs, event order, next-request parity), reasoning-only finalize, half-streamed tool-call dropping, and the nothing-to-finalize case. `packages/llm/llm/tests/assembler.spec.ts` pins `interruptedBlocks()`. The keyless `cancel` ACP snapshot and the goal-session snapshot carry the assembled-application transcript. diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md new file mode 100644 index 0000000000..08abb5c097 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 被取消的流定稿其已送达前缀 + +Status: implemented + +[English](2026-08-10-cancelled-stream-prefix-finalize.md) | 中文 + +## Problem + +轮次在流式输出中途被取消时,被打断的 step 已流出的内容过去会被整体丢弃:`assistant/chunk` 事件留在日志里供回放,但没有任何 `assistant/message` 进入 surface,`deriveMessages()` 不会把被打断的输出带进下一次请求。用户看着文字流出来,客户端在 abort 之后也继续显示它,但从模型的视角那段话从没说过。取消后追问「第二点展开讲讲」接不上,在被取消的轮次上 fork 出的分支继承的 surface 也缺少其主人读过的内容。这个分歧从来不是权衡后的决定:agent loop 的第一版实现就在分片循环里检查 abort 信号并在定稿 append 之前抛出,后来的 surface 白名单把这个形状固化了下来。 + +它违反的主导原则是:用户能看到什么,下一次模型请求就包含什么。 + +## Decision + +`Agent.step()` 让当前流式尝试(assembler、已记录的分片 seq、提供方路由)在请求循环之间保持存活。当 abort 在尝试未提交时逃出 step,`appendInterruptedAssistant` 会在 abort 继续走向 `step/end`/`turn/end` 收尾之前,把该尝试的用户可见前缀定稿为该 step 的普通 `assistant/message`,`surfaceOp: 'append'`,`sourceEventSeqs` 恰好引用已记录的分片。重试决定会先清空尝试:`llm/retry` 会重置客户端渲染的内容,因此其后的 abort 不会从被放弃的尝试中定稿任何东西。 + +`BlockAssembler.interruptedBlocks()` 拥有「什么可以安全定稿」的规则,与既有的 max-tokens 截断规则放在一起:按流顺序保留内容非空白的已闭合与未闭合 `text`/`reasoning` 块。工具调用整块丢弃,因为打断先于分派,保留的调用会要求捏造一个结果;空块和未知类型的未闭合块同样丢弃。没有内容存活时不追加任何事件,轮次保持原有形状:分片、`step/end`、`turn/end` aborted。 + +工具执行期间的取消不受影响:带工具调用的消息此前已定稿,已启动的调用排空为真实结果,未分派的调用保留合成的 `ABORTED_BEFORE_DISPATCH` 对。提供方故障(终局 error 或 aborted finish)仍然不提交任何内容;只有轮次取消定稿前缀,因为只有在那里用户看到过将从模型历史中消失的内容。 + +## Alternatives considered + +**继续丢弃前缀(维持现状)。** 安全且简单,但它让「取消然后转向」这个高频操作每次都制造一个用户可见与模型可见的分裂,fork 也继承这个缺口。否决:分裂的成本反复发生,定稿的成本只付一次。 + +**在请求时从已记录分片投影前缀。** 不加新 surface 事件,让 `deriveMessages()` 为 aborted step 装配分片前缀。否决:它把装配策略搬进每个 surface 消费者,破坏「三类产生消息的事件」的 surface 合同,并让派生历史依赖非 surface 事件。 + +**连完整的工具调用块也定稿,配合成的 aborted 结果。** 保留更多模型意图。否决:这些调用从未分派也从未渲染成工具卡片,对等原则并不要求它们,捏造的结果对还会增加模型可见的噪音;max-tokens 规则本来就丢弃不可分派的调用。 + +**追加显式打断标记(`[interrupted by user]` 用户消息)。** Claude Code 的做法,告诉模型回答是被切断的而不是完整的。搁置而非否决:它是叠加在本次对等修复之上的一个独立的模型可见词汇决定(source 种类、UI 渲染、locale 文案),而持久的 `turn/end aborted` 已经记录了这个事实,未来的投影可以使用。 + +## Consequences + +surface 现在包含取消瞬间用户看到的内容,取消后的追问和 fork 都能接上。cancel 与 goal 两组快照 fixture 记录了定稿前缀事件,ACP 桥在 cancelled stop reason 之后把它作为最后一条 `agent_message_chunk` 更新转发。被打断 step 的 `assistant/message` 现在可能带着一个中途截断的前缀;消费者读到 aborted 的 `turn/end` 即可归类。终局提供方错误保持旧行为,其已流出前缀仍会从 surface 消失,这个不对称是有意留给后续决定的,因为 error 轮次的结束不是用户主动选择的停止。 + +## Testing + +`packages/core/agent-loop/tests/cancel.spec.ts` 固定了流中取消的定稿(内容、引用的 seq、事件顺序、下一请求的对等)、仅 reasoning 的定稿、半流式工具调用的丢弃和无可定稿内容的情形。`packages/llm/llm/tests/assembler.spec.ts` 固定了 `interruptedBlocks()`。keyless 的 `cancel` ACP 快照和 goal-session 快照承载装配后应用的 transcript。 diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index e5dd4edde7..05813580cd 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: a17cae015eaa107a900069de916dddb216b87ec7 -persistence-catalog.zh.md: 3aef073dedcff0b6addb99d7c287f4e5f372c402 +persistence-catalog.md: 54598207c763a1801cd1f6b15d9887bf7875107f +persistence-catalog.zh.md: 7730f03ee220e43f21045ff9d0078241cd352845 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index a17cae015e..54598207c7 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:379`](../packages/core/session/src/types.ts) ## Events @@ -185,14 +185,17 @@ Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/ * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event (undispatched tool calls are absent); an aborted turn with no such + * event streamed no visible content. */ 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } ``` Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts) ### `command/*` @@ -472,7 +475,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -484,7 +487,7 @@ Source: [`packages/core/session/src/types.ts:281`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -537,7 +540,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:307`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -612,7 +615,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) ### `tool/*` @@ -629,7 +632,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -698,7 +701,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) ### `turn/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 3aef073ded..7730f03ee2 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -81,7 +81,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:318`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:379`](../packages/core/session/src/types.ts) ## 事件 @@ -187,14 +187,17 @@ export type SessionEvent = { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event (undispatched tool calls are absent); an aborted turn with no such + * event streamed no visible content. */ 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } ``` 类型:[TokenUsage](subsystems/llm-streaming.md) -来源:[`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts) ### `command/*` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 86ec4e3c3f..1024f88479 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: 4d450c19ec2bbfacabcefc83466e67c8a6c82bd6 -llm-streaming.zh.md: 777c44eaff2b1ee6e5939e04e580d6ad34b1ad1a +llm-streaming.md: 93669b0781a16121765ffafb094ea14c9021e1ec +llm-streaming.zh.md: bab98094f6a9ab3ca0aa9dc6178b46a612b205e8 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4d450c19ec..93669b0781 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -289,6 +289,15 @@ declare class BlockAssembler { * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[]; + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with any streamed content, in stream order. + * Tool calls are dropped whole — interruption precedes dispatch, so a kept + * call would demand a fabricated result — as are empty text/reasoning blocks + * and open blocks of unknown type (there is nothing assembled to keep). + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[]; /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined; /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 777c44eaff..bab98094f6 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -297,6 +297,15 @@ declare class BlockAssembler { * its accumulated deltas (an unknown block type never closed by `block-end` throws). */ blocks(): ContentBlock[]; + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with any streamed content, in stream order. + * Tool calls are dropped whole — interruption precedes dispatch, so a kept + * call would demand a fabricated result — as are empty text/reasoning blocks + * and open blocks of unknown type (there is nothing assembled to keep). + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[]; /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined; /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 3b9f9ce01d..c4dd54291f 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: f5b9e63e2320885cc41b30a09398dd341700152d -session.zh.md: 985e0a448d1cf860ccbb0f2885d855ad6830af9f +session.md: 521dab4e225b871d28ee870abfd3827c3fa999f3 +session.zh.md: 80dc4b4ec4dee11011480e8a28c8d4c0eae92adb diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index f5b9e63e23..521dab4e22 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -59,7 +59,10 @@ interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event (undispatched tool calls are absent); an aborted turn with no such + * event streamed no visible content. */ 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } /** diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 985e0a448d..80dc4b4ec4 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -59,7 +59,10 @@ interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event (undispatched tool calls are absent); an aborted turn with no such + * event streamed no visible content. */ 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } /** diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index f7b0cc84b2..ad0c028dff 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -57,6 +57,7 @@ {"type":"user/message","seq":55,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":58,"time":0,"data":{"turn":3,"step":1}} -{"type":"turn/end","seq":59,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} -{"type":"goal/change","seq":60,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} +{"type":"assistant/message","seq":58,"time":0,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[56,57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":0,"data":{"turn":3,"step":1}} +{"type":"turn/end","seq":60,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"goal/change","seq":61,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl index 9a54cb88c8..c0a4330ea9 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/stdout.expected.jsonl @@ -3,3 +3,4 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL READY"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL ROUND ONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index c6396be679..c46ccc543f 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -10,5 +10,6 @@ {"type":"request/context","seq":8,"time":1785730444532,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1785498791456,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1785730444541,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"step/end","seq":11,"time":1785730444547,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":1785730444547,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} +{"type":"assistant/message","seq":11,"time":1786334791338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"104e9294-f9b8-4248-b7df-0b7e2a069c0a"}},"sourceEventSeqs":[9,10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1786334791338,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":13,"time":1786334791338,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl index cb25d1c6bb..5958634036 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl @@ -1,3 +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","id":3,"result":{"stopReason":"cancelled"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 60ceaa7572..d6f0f5c62c 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/agent-loop/README.md -README.md: 6092363fae2853d6c5d92aaf8cd01e41e18e0b52 -README.zh.md: b65b5334d735a1e0b51fa517ce41c0c953f87cf7 +README.md: fe9f58b8fa9a3851a518300b31c161b45780b80a +README.zh.md: 62f4f63c092251e69d5788275475164fd83c8100 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6092363fae..fe9f58b8fa 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -63,7 +63,7 @@ Every inbox mutation publishes one normalized `agent/inbox/spliced` event before The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Package-private orchestration entry points recover the exact Agent, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation `Session` through shallow interfaces. A helper keeps an explicit `Session` when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules. -Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, lists the exact chunk seqs in `sourceEventSeqs` (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. +Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, lists the exact chunk seqs in `sourceEventSeqs` (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history. A turn cancellation that interrupts streaming also appends one anchor when visible content already streamed: the logged chunks reached the user, so the closed and open text/reasoning blocks they assemble join the surface, citing those chunk seqs — the next request contains what the user saw. Tool calls are dropped whole (interruption precedes dispatch), and nothing is appended when only empty or undispatchable content streamed; provider failures still commit nothing ([decision](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md)). After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance follows the same adapter-default marker rule when resuming. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index b65b5334d7..62f4f63c09 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -63,7 +63,7 @@ interface Config { 驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。包私有的编排入口点会恢复确切的 Agent,一次性派生 `agent.session`,并让操作局部的辅助函数捕获它,而不是通过浅层接口继续传递具体驱动器或每次操作的 `Session`。如果显式 `Session` 正是辅助函数的实际接口,该辅助函数会保留它;创建、持久化加载、未发布 setup、服务、worker、进程、持久化和 wire 协议则继续保留各自的显式身份。[agent 服务](../agent/README.md#initiating-agent-scope)规定传播、teardown 和分离工作规则。 -每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,在 `sourceEventSeqs` 中列出确切的分片 seq(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。 +每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,在 `sourceEventSeqs` 中列出确切的分片 seq(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。打断流式输出的轮次取消在已流出可见内容时同样追加一个锚点:已记录的分片已经送达用户,因此由它们组装出的已闭合与未闭合 text/reasoning 块进入 surface,并引用这些分片 seq,因此下一次请求包含用户看到的内容。工具调用整块丢弃(打断先于分派),只流出空内容或不可分派内容时不追加任何事件;提供方故障仍然不提交任何内容([决策](../../../.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md))。 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会遵循同一套适配器默认值标记规则。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6ef965e59e..c3fb9737b8 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -51,6 +51,14 @@ type PreparedStep = | { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly } +/** One live streaming attempt whose logged chunk prefix an abort can still finalize. */ +interface InterruptedAttempt { + readonly assembler: BlockAssembler + readonly chunkSeqs: number[] + readonly provider: string + readonly model: string +} + /** Remove adapter-derived values before plugins propose the next request config. */ function requestProposal(header: EpochHeader): LlmCallConfig { if (header.adapterDefaults === undefined) return header.config @@ -336,70 +344,112 @@ export class ReactLoopAgent implements Agent { signal.throwIfAborted() const system = renderPrompt(assembly) - while (true) { - const { request, preparedCall } = await this.buildRequest( - turn, step, assembly.tools, system, this.session.deriveMessages(), signal, - ) - const assembler = new BlockAssembler() - const chunkSeqs: number[] = [] - const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) - signal.throwIfAborted() - for await (const chunk of stream) { + // The streaming attempt an abort may still finalize: chunks already logged + // reached the user, so cancellation commits their assemblable prefix to the + // surface instead of dropping it (see appendInterruptedAssistant). Cleared + // once the attempt commits normally or a retry resets the visible stream. + let attempt: InterruptedAttempt | undefined + try { + while (true) { + const { request, preparedCall } = await this.buildRequest( + turn, step, assembly.tools, system, this.session.deriveMessages(), signal, + ) + const assembler = new BlockAssembler() + const chunkSeqs: number[] = [] + attempt = { assembler, chunkSeqs, provider: request.provider, model: request.model } + const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) signal.throwIfAborted() - chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) - assembler.push(chunk) - } - signal.throwIfAborted() - const finish = assembler.finish - if (finish.kind === 'error' || finish.kind === 'aborted') { - const action = await this.dispatch.waterfall( - 'agent/request-error', { + for await (const chunk of stream) { + signal.throwIfAborted() + chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) + assembler.push(chunk) + } + signal.throwIfAborted() + const finish = assembler.finish + if (finish.kind === 'error' || finish.kind === 'aborted') { + const action = await this.dispatch.waterfall( + 'agent/request-error', { + turn, + step, + provider: request.provider, + failure: finish.failure, + retryPolicy: preparedCall?.retryPolicy, + signal, + }, + () => Promise.resolve(undefined), + ) + signal.throwIfAborted() + if (action?.kind !== 'retry') { + throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) + } + attempt = undefined + continue + } + + const message = createAssistantMessage({ + content: assembler.blocks(), + source: { + provider: request.provider, + model: request.model, + ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, + }, + }) + attempt = undefined + this.session.append( + 'assistant/message', + { turn, step, - provider: request.provider, - failure: finish.failure, - retryPolicy: preparedCall?.retryPolicy, - signal, + message, + ...assembler.usage === undefined ? {} : { usage: assembler.usage }, }, - () => Promise.resolve(undefined), + { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, ) - signal.throwIfAborted() - if (action?.kind !== 'retry') { - throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) - } - continue + if (finish.kind === 'max-tokens') return { kind: 'max-tokens' } + + const toolCalls = message.content.filter(block => block.type === 'tool-call') + if (toolCalls.length === 0) return { kind: 'completed' } + const { concluded } = await executeToolCalls( + this.loopCtx, turn, step, toolCalls, signal, + context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), + ) + return concluded ? { kind: 'completed' } : null } - - const message = createAssistantMessage({ - content: assembler.blocks(), - source: { - provider: request.provider, - model: request.model, - ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {}, - }, - }) - this.session.append( - 'assistant/message', - { - turn, - step, - message, - ...assembler.usage === undefined ? {} : { usage: assembler.usage }, - }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, - ) - if (finish.kind === 'max-tokens') return { kind: 'max-tokens' } - - const toolCalls = message.content.filter(block => block.type === 'tool-call') - if (toolCalls.length === 0) return { kind: 'completed' } - const { concluded } = await executeToolCalls( - this.loopCtx, turn, step, toolCalls, signal, - context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), - ) - return concluded ? { kind: 'completed' } : null + } catch (error: unknown) { + if (signal.aborted && attempt !== undefined) { + this.appendInterruptedAssistant(turn, step, attempt) + } + throw error } } + /** + * Finalize a cancelled streaming attempt's user-visible prefix onto the + * surface: everything already logged as `assistant/chunk` events was + * delivered to the user, and the next request must contain what the user saw. + * Keeps the assembler's interrupted-safe blocks (text/reasoning; tool calls + * were never dispatched and are dropped); appends nothing when no visible + * content streamed before the interruption. + */ + private appendInterruptedAssistant(turn: number, step: number, attempt: InterruptedAttempt): void { + const content = attempt.assembler.interruptedBlocks() + if (content.length === 0) return + const message = createAssistantMessage({ + content, + source: { provider: attempt.provider, model: attempt.model }, + }) + this.session.append( + 'assistant/message', + { + turn, + step, + message, + ...attempt.assembler.usage === undefined ? {} : { usage: attempt.assembler.usage }, + }, + { surfaceOp: 'append', sourceEventSeqs: attempt.chunkSeqs }, + ) + } + /** * Compose one frozen request and bind it to the adapter registration that * resolved its exact-model defaults. diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 6fb77d141c..f8ecc4680f 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,4 +1,4 @@ -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' /** * Tests for the queue-aware `Agent.cancel()` primitive. The default clears * queued and steering work, while `keepInbox` preserves pending input for a @@ -479,6 +479,104 @@ describe('Agent.cancel()', () => { expect(reasons.length).toBe(2) }) + it('cancel mid-stream finalizes the streamed prefix onto the surface', async () => { + const adapter = new MockAdapter(['hang', textResponse('after')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('partial-finalize'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + // The prefix the user watched stream is committed as the step's message, + // citing exactly the chunk events that delivered it. + const message = agent.session.events.find(e => e.type === 'assistant/message') + expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'text', text: 'partial' }]) + const chunkSeqs = agent.session.events.filter(e => e.type === 'assistant/chunk').map(e => e.seq) + expect(message?.sourceEventSeqs).toEqual(chunkSeqs) + const types = agent.session.events.map(e => e.type) + expect(types.indexOf('assistant/message')).toBeLessThan(types.indexOf('step/end')) + expect(types.indexOf('step/end')).toBeLessThan(types.indexOf('turn/end')) + + // The next request derives the finalized prefix: the model sees what the user saw. + send(agent, 'continue') + await waitForIdle(ctx, agent) + const replayed = adapter.requests[1]!.messages + .filter(m => m.role === 'assistant') + .flatMap(m => m.content) + .flatMap(b => b.type === 'text' ? [b.text] : []) + expect(replayed).toContain('partial') + }) + + it('cancel during reasoning-only streaming finalizes the reasoning prefix', async () => { + const adapter = new MockAdapter([{ + hangAfter: [ + { type: 'block-start', index: 0, blockType: 'reasoning' }, + { type: 'reasoning-delta', index: 0, text: 'thinking about it' }, + { type: 'usage', usage: { inputTokens: 7, outputTokens: 4 } }, + ], + }]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('reasoning-finalize'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + const message = agent.session.events.find(e => e.type === 'assistant/message') + expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'reasoning', text: 'thinking about it' }]) + // A usage chunk delivered before the cancel travels with the finalized prefix. + expect(message?.type === 'assistant/message' ? message.data.usage : undefined) + .toEqual({ inputTokens: 7, outputTokens: 4 }) + }) + + it('cancel drops a half-streamed tool call and keeps the completed text before it', async () => { + const adapter = new MockAdapter([{ + hangAfter: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'reading the file' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'reading the file' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read', argumentsDelta: '{"pa' }, + ], + }]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('tool-call-drop'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + // The undispatched call is dropped whole — no dangling tool_use to pair. + const message = agent.session.events.find(e => e.type === 'assistant/message') + expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'text', text: 'reading the file' }]) + expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) + }) + + it('cancel before any visible content finalizes nothing', async () => { + const adapter = new MockAdapter([{ + hangAfter: [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'read', argumentsDelta: '{"pa' }, + ], + }]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('nothing-to-finalize'), { provider: 'mock', model: 'mock' }) + + send(agent, 'go') + await new Promise(r => setTimeout(r, 30)) + agent.cancel({ kind: 'user' }) + await waitForIdle(ctx, agent) + + expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + }) + it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 69dc78990c..c5341fb7ed 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -55,19 +55,25 @@ export function toolCallResponse(rawCallId: string, name: string, args: object, return chunks } +/** Script entry that streams the given chunks, then hangs until aborted. */ +export interface HangAfter { + hangAfter: StreamChunk[] +} + /** * Mock adapter driven by a script: each model call consumes the next entry. * Records every request it receives for assertions. An entry may be a * function to compute chunks from the request, a 'hang' marker that - * streams one chunk then waits until aborted, or 'hang-slow' which takes + * streams one chunk then waits until aborted, 'hang-slow' which takes * 50ms to notice the abort — a stand-in for slow real-world teardown - * (LLM stream cancellation, tool unwinding). + * (LLM stream cancellation, tool unwinding) — or a {@link HangAfter} + * scripting the exact chunks delivered before the hang. */ export class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] constructor( - private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow')[], + private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow' | HangAfter)[], private readonly reasoning?: LlmModelReasoningInfo, private readonly defaultMaxTokens?: number, ) { @@ -100,6 +106,14 @@ export class MockAdapter extends LlmAdapter { }) return } + if (typeof entry === 'object' && !Array.isArray(entry) && 'hangAfter' in entry) { + for (const chunk of entry.hangAfter) yield chunk + await new Promise((_resolve, reject) => { + if (options.signal?.aborted) { reject(new Error('aborted')); return } + options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + return + } if (entry === 'hang-slow') { yield { type: 'block-start', index: 0, blockType: 'text' } yield { type: 'text-delta', index: 0, text: 'partial' } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 6074b51c02..e0d3a9b196 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -240,7 +240,10 @@ export interface SessionEventMap { * Assembled assistant message for one step (derived history uses this). * Carries the step's `usage` when the adapter reported token accounting, so * the model output and its accounting travel together (there is no separate - * usage record). `usage` is absent when the adapter reported none. + * usage record). `usage` is absent when the adapter reported none. A turn + * cancelled mid-stream finalizes its delivered text/reasoning prefix as this + * event (undispatched tool calls are absent); an aborted turn with no such + * event streamed no visible content. */ 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } /** diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index a0e1332417..d65a1bc796 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -138,6 +138,26 @@ export class BlockAssembler { : blocks } + /** + * Assemble the prefix an interrupted stream can safely finalize: closed and + * open text/reasoning blocks with any streamed content, in stream order. + * Tool calls are dropped whole — interruption precedes dispatch, so a kept + * call would demand a fabricated result — as are empty text/reasoning blocks + * and open blocks of unknown type (there is nothing assembled to keep). + * @returns the kept blocks; empty when nothing streamed before the interruption. + */ + interruptedBlocks(): ContentBlock[] { + return this.order + .map((index) => { + const partial = this.mustGet(index) + const type = partial.block?.type ?? partial.blockType + if (type !== 'text' && type !== 'reasoning') return undefined + return this.assemble(partial, index) + }) + .filter((block): block is ContentBlock => + (block?.type === 'text' || block?.type === 'reasoning') && block.text.trim() !== '') + } + /** Usage from the `usage` chunk; undefined until one arrives. */ get usage(): TokenUsage | undefined { return this._usage diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index bf2276a218..a121d303b6 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -145,3 +145,41 @@ describe('BlockAssembler duplicate-close contract', () => { expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) }) }) + +describe('BlockAssembler.interruptedBlocks', () => { + it('keeps closed and open text/reasoning blocks with streamed content, in order', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'reasoning' }) + assembler.push({ type: 'reasoning-delta', index: 0, text: 'planning' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'reasoning', text: 'planning' } }) + assembler.push({ type: 'block-start', index: 1, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 1, text: 'half an ans' }) + expect(assembler.interruptedBlocks()).toEqual([ + { type: 'reasoning', text: 'planning' }, + { type: 'text', text: 'half an ans' }, + ]) + }) + + it('drops tool calls whether open or closed — interruption precedes dispatch', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: 'calling' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'calling' } }) + assembler.push({ type: 'block-start', index: 1, blockType: 'tool-call' }) + assembler.push({ type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read', argumentsDelta: '{"a":1}' }) + assembler.push({ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"a":1}' } }) + assembler.push({ type: 'block-start', index: 2, blockType: 'tool-call' }) + assembler.push({ type: 'tool-call-delta', index: 2, id: CallId('c2'), name: 'read', argumentsDelta: '{"pa' }) + expect(assembler.interruptedBlocks()).toEqual([{ type: 'text', text: 'calling' }]) + }) + + it('drops empty and whitespace-only text/reasoning blocks and unknown open block types', () => { + const assembler = new BlockAssembler() + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: ' \n' }) + // A merge-extended block kind this build does not know how to assemble. + assembler.push({ type: 'block-start', index: 1, blockType: 'mystery' } as unknown as StreamChunk) + assembler.push({ type: 'block-start', index: 2, blockType: 'reasoning' }) + expect(assembler.interruptedBlocks()).toEqual([]) + }) +}) From 87f24bb9917a9f989ce217d51eb29b3b48eb9fca Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 13:10:43 +0800 Subject: [PATCH 006/137] fix(agent-loop): mark finalized prefixes durable-interrupted; never finalize failed attempts Review round findings: - Clear the streaming attempt before the request-error waterfall: a cancel landing during recovery (typically the llm/retry backoff, after clients reset the streamed rendering) must not resurrect the failed stream's prefix. Provider failures commit nothing, now including that window. - Record interrupted: true on the cancellation-finalized assistant/message. The chat projection keeps the settled prefix classified as interrupted (Stopped chip, restored web goldens), and request inspection leaves the request uncompleted so the step boundary classifies it as before. - Pin the recovery-window and retry-discard semantics with content-bearing failed streams in cancel.spec; update the ACP late-end expectation to the finalized-prefix transcript. - Mention interruptedBlocks() in the assembler stream-lifecycle JSDoc. --- ...cancelled-stream-prefix-finalize.i18n.yaml | 4 +- ...-08-10-cancelled-stream-prefix-finalize.md | 4 +- ...-10-cancelled-stream-prefix-finalize.zh.md | 4 +- .../live-interactions/cancel.expected.md | 4 +- .../queue-actions/preserved.expected.md | 4 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 23 ++++---- docs/persistence-catalog.zh.md | 23 ++++---- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 3 +- docs/subsystems/llm-streaming.zh.md | 3 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 7 ++- docs/subsystems/session.zh.md | 7 ++- .../goal-session/session.expected.jsonl | 2 +- .../tests/snapshots/cancel/session.jsonl | 2 +- .../cordis-inspect-jsdoc/session.jsonl | 2 +- .../advanced-toolchain/session.1.jsonl | 8 +-- .../advanced-toolchain/session.2.jsonl | 8 +-- .../advanced-toolchain/session.jsonl | 28 ++++----- .../tests/snapshots/pty-tools/session.jsonl | 34 +++++------ packages/acp/acp/tests/turns.spec.ts | 4 +- .../src/client/sessions/request-inspection.ts | 5 +- .../runtime/tests/request-inspection.spec.ts | 25 ++++++++ .../client/conversation-nodes/assistant.ts | 4 ++ .../conversation-node-definitions.spec.ts | 16 +++++ packages/core/agent-loop/src/agent.ts | 7 ++- packages/core/agent-loop/tests/cancel.spec.ts | 59 ++++++++++++++++++- packages/core/session/src/types.ts | 7 ++- packages/llm/llm/src/assembler.ts | 3 +- .../tool-cordis/src/api-catalog.ts | 2 +- 31 files changed, 217 insertions(+), 97 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml index 9c577a6c74..21ba2beb71 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.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-cancelled-stream-prefix-finalize.md -2026-08-10-cancelled-stream-prefix-finalize.md: a2201cd17fd89744590bd4024bff80f3e3835e39 -2026-08-10-cancelled-stream-prefix-finalize.zh.md: 08abb5c097bdf3c6c21e0a96873ab4a995e8f3e9 +2026-08-10-cancelled-stream-prefix-finalize.md: 34b68ff30e399d75b2ad4417bd3e2b8c25cd5757 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: febd96f8179433fb7411eed6c533e95bcfad2648 diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md index a2201cd17f..34b68ff30e 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.md @@ -12,7 +12,7 @@ The governing principle this violated: what the user can see, the next model req ## Decision -`Agent.step()` keeps the current streaming attempt (assembler, logged chunk seqs, provider route) alive across the request loop. When an abort escapes the step while an attempt is uncommitted, `appendInterruptedAssistant` finalizes the attempt's user-visible prefix as the step's ordinary `assistant/message` — `surfaceOp: 'append'`, `sourceEventSeqs` citing exactly the logged chunks — before the abort continues to the `step/end`/`turn/end` teardown. A retry decision clears the attempt first: `llm/retry` resets what clients render, so an abort after it finalizes nothing from the abandoned attempt. +`Agent.step()` keeps the current streaming attempt (assembler, logged chunk seqs, provider route) alive across the request loop. When an abort escapes the step while an attempt is uncommitted, `appendInterruptedAssistant` finalizes the attempt's user-visible prefix as the step's `assistant/message` with `interrupted: true` — `surfaceOp: 'append'`, `sourceEventSeqs` citing exactly the logged chunks — before the abort continues to the `step/end`/`turn/end` teardown. The durable marker is the classification consumers read: the chat projection keeps rendering the settled prefix as interrupted (the Stopped chip), and request inspection leaves the request uncompleted so the step boundary classifies it as before. An attempt that ends in an `error`/`aborted` finish is cleared before the recovery waterfall runs: provider failures commit nothing, and a cancel landing during recovery (typically the `llm/retry` backoff, after clients reset the streamed rendering) must not resurrect the failed stream's prefix. `BlockAssembler.interruptedBlocks()` owns what is safe to finalize, next to the existing max-tokens truncation rule: closed and open `text`/`reasoning` blocks with non-whitespace content, in stream order. Tool calls are dropped whole — interruption precedes dispatch, so a kept call would demand a fabricated result — as are empty blocks and open blocks of unknown type. When nothing survives, no event is appended and the turn keeps its previous shape: chunks, `step/end`, `turn/end` aborted. @@ -30,7 +30,7 @@ Cancellation during tool execution is untouched: the tool-call message was alrea ## Consequences -The surface now contains what the user saw at the moment of cancellation, so post-cancel follow-ups and forks connect. The cancel and goal snapshot fixtures record the finalized prefix event, and the ACP bridge forwards it as a final `agent_message_chunk` update after the cancelled stop reason. An interrupted step's `assistant/message` can now carry a mid-sentence prefix; consumers reading the aborted `turn/end` can classify it. Terminal provider errors keep the old behavior — their streamed prefix still vanishes from the surface — an asymmetry deliberately left for a follow-up decision because error turns end without the user choosing to stop. +The surface now contains what the user saw at the moment of cancellation, so post-cancel follow-ups and forks connect. The cancel and goal snapshot fixtures record the finalized prefix event, and the ACP bridge forwards it as a final `agent_message_chunk` update after the cancelled stop reason — prompt settlement does not wait on loop teardown, so automation clients may receive the update after the cancelled stop reason. An interrupted step's `assistant/message` carries a mid-sentence prefix and the `interrupted: true` marker that classifies it. Terminal provider errors keep the old behavior — their streamed prefix still vanishes from the surface — an asymmetry deliberately left for a follow-up decision because error turns end without the user choosing to stop. ## Testing diff --git a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md index 08abb5c097..febd96f817 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-cancelled-stream-prefix-finalize.zh.md @@ -12,7 +12,7 @@ Status: implemented ## Decision -`Agent.step()` 让当前流式尝试(assembler、已记录的分片 seq、提供方路由)在请求循环之间保持存活。当 abort 在尝试未提交时逃出 step,`appendInterruptedAssistant` 会在 abort 继续走向 `step/end`/`turn/end` 收尾之前,把该尝试的用户可见前缀定稿为该 step 的普通 `assistant/message`,`surfaceOp: 'append'`,`sourceEventSeqs` 恰好引用已记录的分片。重试决定会先清空尝试:`llm/retry` 会重置客户端渲染的内容,因此其后的 abort 不会从被放弃的尝试中定稿任何东西。 +`Agent.step()` 让当前流式尝试(assembler、已记录的分片 seq、提供方路由)在请求循环之间保持存活。当 abort 在尝试未提交时逃出 step,`appendInterruptedAssistant` 会在 abort 继续走向 `step/end`/`turn/end` 收尾之前,把该尝试的用户可见前缀定稿为该 step 的带 `interrupted: true` 的 `assistant/message`,`surfaceOp: 'append'`,`sourceEventSeqs` 恰好引用已记录的分片。这个持久标记就是消费者读取的分类:chat 投影继续把定稿前缀渲染为被打断(Stopped 徽章),请求检查让该请求保持未完成,由 step 边界照旧归类。以 `error`/`aborted` finish 结束的尝试会在恢复 waterfall 运行前被清空:提供方故障不提交任何内容,落在恢复期间的取消(典型是 `llm/retry` 退避期,此时客户端已重置流式渲染)不得复活失败流的前缀。 `BlockAssembler.interruptedBlocks()` 拥有「什么可以安全定稿」的规则,与既有的 max-tokens 截断规则放在一起:按流顺序保留内容非空白的已闭合与未闭合 `text`/`reasoning` 块。工具调用整块丢弃,因为打断先于分派,保留的调用会要求捏造一个结果;空块和未知类型的未闭合块同样丢弃。没有内容存活时不追加任何事件,轮次保持原有形状:分片、`step/end`、`turn/end` aborted。 @@ -30,7 +30,7 @@ Status: implemented ## Consequences -surface 现在包含取消瞬间用户看到的内容,取消后的追问和 fork 都能接上。cancel 与 goal 两组快照 fixture 记录了定稿前缀事件,ACP 桥在 cancelled stop reason 之后把它作为最后一条 `agent_message_chunk` 更新转发。被打断 step 的 `assistant/message` 现在可能带着一个中途截断的前缀;消费者读到 aborted 的 `turn/end` 即可归类。终局提供方错误保持旧行为,其已流出前缀仍会从 surface 消失,这个不对称是有意留给后续决定的,因为 error 轮次的结束不是用户主动选择的停止。 +surface 现在包含取消瞬间用户看到的内容,取消后的追问和 fork 都能接上。cancel 与 goal 两组快照 fixture 记录了定稿前缀事件,ACP 桥在 cancelled stop reason 之后把它作为最后一条 `agent_message_chunk` 更新转发,prompt 的结算不等待循环收尾,因此自动化客户端可能在 cancelled stop reason 之后才收到该更新。被打断 step 的 `assistant/message` 带着中途截断的前缀和用于归类的 `interrupted: true` 标记。终局提供方错误保持旧行为,其已流出前缀仍会从 surface 消失,这个不对称是有意留给后续决定的,因为 error 轮次的结束不是用户主动选择的停止。 ## Testing diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 1d87f01525..bb290942b3 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -19,7 +19,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} - textbox "Message the agent" - button "Commands": - img @@ -28,4 +28,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} Input 0 tok · Output 0 tok diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index a845590873..ac1740d0c8 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -19,7 +19,7 @@ - img - button "Branch into a new conversation": - img -- text: {{clock}} Ran for {{duration}} +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} - button "2 queued messages" [expanded] - list: - listitem: @@ -47,4 +47,4 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 1 steps Input 0 tok · Output 0 tok +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} Input 0 tok · Output 0 tok diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index d427910f80..17187309ad 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: 96374f4488dbfd4e60da99532ae2e559a3855c0f -persistence-catalog.zh.md: c39e5db3deb01305ae687b79e2ee252f29bca55d +persistence-catalog.md: 09f440514335fdc24b9abd036c448818aa8a4465 +persistence-catalog.zh.md: 6fe6b46375d30505fcea742c31d5cf6701491c95 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 96374f4488..09f4405143 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -79,7 +79,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:387`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:388`](../packages/core/session/src/types.ts) ## Events @@ -203,15 +203,16 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. A turn * cancelled mid-stream finalizes its delivered text/reasoning prefix as this - * event (undispatched tool calls are absent); an aborted turn with no such - * event streamed no visible content. + * event with `interrupted: true` (undispatched tool calls are absent), so + * consumers classify the truncation without re-deriving it from turn + * boundaries; an aborted turn with no such event streamed no visible content. */ -'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } +'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } ``` Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) ### `command/*` @@ -491,7 +492,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -503,7 +504,7 @@ Source: [`packages/core/session/src/types.ts:292`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -556,7 +557,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -631,7 +632,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) ### `tool/*` @@ -648,7 +649,7 @@ Source: [`packages/core/session/src/types.ts:282`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -717,7 +718,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) ### `turn/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c39e5db3de..6fe6b46375 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -81,7 +81,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:319`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:326`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:387`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:388`](../packages/core/session/src/types.ts) ## 事件 @@ -205,15 +205,16 @@ export type SessionEvent = { * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. A turn * cancelled mid-stream finalizes its delivered text/reasoning prefix as this - * event (undispatched tool calls are absent); an aborted turn with no such - * event streamed no visible content. + * event with `interrupted: true` (undispatched tool calls are absent), so + * consumers classify the truncation without re-deriving it from turn + * boundaries; an aborted turn with no such event streamed no visible content. */ -'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } +'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } ``` 类型:[TokenUsage](subsystems/llm-streaming.md) -来源:[`packages/core/session/src/types.ts:256`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:257`](../packages/core/session/src/types.ts) ### `command/*` @@ -493,7 +494,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:292`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:293`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -505,7 +506,7 @@ export type SessionEvent = { 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -来源:[`packages/core/session/src/types.ts:287`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -558,7 +559,7 @@ export type SessionEvent = { 'session/end-seed': Record ``` -来源:[`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -633,7 +634,7 @@ export type SessionEvent = { 类型:[TodoItem](subsystems/session.md) -来源:[`packages/core/session/src/types.ts:282`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:283`](../packages/core/session/src/types.ts) ### `tool/*` @@ -650,7 +651,7 @@ export type SessionEvent = { 类型:[CallId](subsystems/core.md) -来源:[`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:263`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -719,7 +720,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) ### `turn/*` diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 1024f88479..032d37ef30 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: 93669b0781a16121765ffafb094ea14c9021e1ec -llm-streaming.zh.md: bab98094f6a9ab3ca0aa9dc6178b46a612b205e8 +llm-streaming.md: 7ab12d8e26b5bedbde8a20e661b049336ed2a459 +llm-streaming.zh.md: f0fe8c1e724f7e58251a772da5c5fc9a1ff3d9e9 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 93669b0781..7ab12d8e26 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -270,7 +270,8 @@ interface TokenUsage { * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index bab98094f6..f0fe8c1e72 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -278,7 +278,8 @@ interface TokenUsage { * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 4ee96ac032..36ce517713 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: 3ffd7997afaf93633a034ffb8ebd308bd18e773d -session.zh.md: 9a89f42c5a7f8bf0ac82ab0d9ca96be506d18876 +session.md: 7c26bbc1cfd197adf139c353c610ce2d951f0170 +session.zh.md: b05bbd52eec45fbd5520f1b6cc5e343b8e80950b diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 3ffd7997af..7c26bbc1cf 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -61,10 +61,11 @@ interface SessionEventMap { * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. A turn * cancelled mid-stream finalizes its delivered text/reasoning prefix as this - * event (undispatched tool calls are absent); an aborted turn with no such - * event streamed no visible content. + * event with `interrupted: true` (undispatched tool calls are absent), so + * consumers classify the truncation without re-deriving it from turn + * boundaries; an aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 9a89f42c5a..b05bbd52ee 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -61,10 +61,11 @@ interface SessionEventMap { * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. A turn * cancelled mid-stream finalizes its delivered text/reasoning prefix as this - * event (undispatched tool calls are absent); an aborted turn with no such - * event streamed no visible content. + * event with `interrupted: true` (undispatched tool calls are absent), so + * consumers classify the truncation without re-deriving it from turn + * boundaries; an aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl index ad0c028dff..ab905a7235 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl @@ -57,7 +57,7 @@ {"type":"user/message","seq":55,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal-session snapshot proof\"\nRound: 2/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":2},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":3,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"assistant/message","seq":58,"time":0,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[56,57],"surfaceOp":"append"} +{"type":"assistant/message","seq":58,"time":0,"data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"interrupted":true},"sourceEventSeqs":[56,57],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":0,"data":{"turn":3,"step":1}} {"type":"turn/end","seq":60,"time":0,"data":{"turn":3,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} {"type":"goal/change","seq":61,"time":0,"data":{"kind":"goal/change","version":1,"operation":"pause","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal-session snapshot proof","phase":"paused","maxGoalRounds":2},"roundsStarted":2,"createdAt":0,"updatedAt":0}} diff --git a/examples/acp-agent/tests/snapshots/cancel/session.jsonl b/examples/acp-agent/tests/snapshots/cancel/session.jsonl index c46ccc543f..af088a8ff7 100644 --- a/examples/acp-agent/tests/snapshots/cancel/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/session.jsonl @@ -10,6 +10,6 @@ {"type":"request/context","seq":8,"time":1785730444532,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1785498791456,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1785730444541,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"partial"}}} -{"type":"assistant/message","seq":11,"time":1786334791338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"104e9294-f9b8-4248-b7df-0b7e2a069c0a"}},"sourceEventSeqs":[9,10],"surfaceOp":"append"} +{"type":"assistant/message","seq":11,"time":1786334791338,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"104e9294-f9b8-4248-b7df-0b7e2a069c0a"},"interrupted":true},"sourceEventSeqs":[9,10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1786334791338,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":13,"time":1786334791338,"data":{"turn":1,"reason":{"kind":"aborted","reason":{"kind":"user"}}}} 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 6d339a4512..99e513903b 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 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 '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 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":"2ec5ca51-ec8b-4756-8c71-c20fb871b421"}},"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 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 '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 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 interrupted?: true;\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":"63624313-7ef7-4c74-af4a-a96dbc850d79"}},"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"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 44cbf0e360..c4d8d2109a 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,18 +1,18 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"adc87967-3604-494d-9a0c-d3ba992321b5"}]}} {"type":"turn/start","seq":1,"time":1785821454445,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454445,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454466,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":1785730501506,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"adc87967-3604-494d-9a0c-d3ba992321b5"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730501506,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":7,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} {"type":"assistant/chunk","seq":12,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":13,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cac680cf-1d70-4fb2-91a3-da1e3a317d2e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a0d8fae-1484-40f9-8fbc-a21bd6e34f4b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1785730501507,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":16,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 6988595618..57af223034 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,18 +1,18 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"80153740-f1c2-4c68-a021-f683b7299f9a"}]}} {"type":"turn/start","seq":1,"time":1785821454599,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454599,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454618,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":1785730501645,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"80153740-f1c2-4c68-a021-f683b7299f9a"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730501645,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":7,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} {"type":"assistant/chunk","seq":12,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":13,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2b31dae5-8939-44e1-bbcd-9f64aa637d76"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c0e12068-d11c-470b-b579-d3e1f965d3dc"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1785730501646,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":16,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 646110b6d9..eb7ab599fb 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"660f770b-8f37-4cb9-8bfb-7e588e297ad7"}]}} {"type":"turn/start","seq":1,"time":1785821454304,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454304,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"660f770b-8f37-4cb9-8bfb-7e588e297ad7"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e65c0ebe-8e3d-44c0-833f-68efcbc0acb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"34d67879-3a88-4396-a1b0-127bae0e1ccf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"abb8ecee-cb03-4a66-9477-38a52458ab05"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"157dc2aa-743a-4564-b1d3-472c25bf5831"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501413,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1785730501423,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,11 +22,11 @@ {"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9d5b359b-f2e6-4648-aa0c-89fac1938f30"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} {"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"f8b90fd0-fabb-4e8d-8645-b10f7674c998"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -34,9 +34,9 @@ {"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba4958e9-231c-437f-a2fc-7a13f392d3ba"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"eb0416ad-752f-4c9a-a6c0-066e955c36d6"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} {"type":"tool/call","seq":36,"time":1785730501484,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"b9ebb37d-e565-4882-95b0-5343da1d68d8"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"d14db6df-b6fa-4cd4-aa8e-701ebe0bcab7"}},"sourceEventSeqs":[36],"surfaceOp":"append"} {"type":"step/end","seq":38,"time":1785730501508,"data":{"turn":1,"step":3}} {"type":"step/start","seq":39,"time":1785730501521,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -44,9 +44,9 @@ {"type":"assistant/chunk","seq":42,"time":1785037378946,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4757f4b9-9bde-488b-a54a-1bdea55dd15f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"69a4bcfc-a8af-41e5-827a-704a9ac4a84f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} {"type":"tool/call","seq":46,"time":1785730501522,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"35baa460-54ff-4fa1-ba9d-66b6661f84e9"}},"sourceEventSeqs":[46],"surfaceOp":"append"} +{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"5a027b96-5860-408c-86d3-d7c1306b4640"}},"sourceEventSeqs":[46],"surfaceOp":"append"} {"type":"step/end","seq":48,"time":1785730501648,"data":{"turn":1,"step":4}} {"type":"step/start","seq":49,"time":1785730501660,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -54,9 +54,9 @@ {"type":"assistant/chunk","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":53,"time":1785498584085,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"739166e2-ed48-4df2-a9a5-207f34058030"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} +{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f552f360-52c4-4d68-96c2-e86b9cab792e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} {"type":"tool/call","seq":56,"time":1785730501661,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"98b05c06-cb77-41a9-8310-324bc72fc7a0"}},"sourceEventSeqs":[56],"surfaceOp":"append"} +{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"cc001237-dcec-42bd-a728-c580d3028e17"}},"sourceEventSeqs":[56],"surfaceOp":"append"} {"type":"step/end","seq":58,"time":1785730501668,"data":{"turn":1,"step":5}} {"type":"step/start","seq":59,"time":1785730501678,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -64,6 +64,6 @@ {"type":"assistant/chunk","seq":62,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","seq":63,"time":1785498584102,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a4ca8f2-92c1-4dbc-beb8-923b8791c298"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} +{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"acc0c8e0-97ea-4225-a735-9a54193c20d8"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} {"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index f4f592570a..d94f0009aa 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -1,21 +1,21 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498587408,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"39f39ecc-5772-4814-8feb-46433c71becd"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498587408,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d05853ba-1ee0-477e-b673-92a955b98724"}]}} {"type":"turn/start","seq":1,"time":1785821457966,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821457966,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"39f39ecc-5772-4814-8feb-46433c71becd"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730504659,"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."}],"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."}]},"role":"user","id":"a5ae9c04-0652-436f-9b5a-437a3a6ed235"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d05853ba-1ee0-477e-b673-92a955b98724"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730504659,"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."}],"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."}]},"role":"user","id":"d4ac87a7-eed3-41fd-bf5d-e6d49f331f66"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730504659,"data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498587438,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"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":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":7,"time":1785498587438,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model.\n\nVerify your work by running the code or tests. Keep answers brief and\nfactual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nUse a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"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":"terminal_close","description":"Close one persistent terminal and wait until its captured owned process tree is gone.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."}},"required":["sessionId"]}},{"name":"terminal_list","description":"List persistent terminal sessions owned by the current agent.","parameters":{"type":"object","properties":{}}},{"name":"terminal_open","description":"Create a persistent, owner-isolated terminal session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.","parameters":{"type":"object","properties":{"type":{"type":"string","description":"Registered terminal backend type, usually \"shell\"."},"name":{"type":"string","description":"Optional owner-local display name such as \"main\" or \"gdb\"."},"cwd":{"type":"string","description":"Initial working directory. Defaults to the deployment workspace root."}},"required":["type"]}},{"name":"terminal_read","description":"Read a bounded page of retained output from a persistent terminal without sending input.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"offset":{"type":"number","description":"Newest-relative line offset (default 0)."},"count":{"type":"number","description":"Requested line count (default 500; backend caps apply)."}},"required":["sessionId"]}},{"name":"terminal_send","description":"Send text to a persistent terminal. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id returned by terminal_open or terminal_list."},"text":{"type":"string","description":"UTF-8 text to write to the terminal."},"submit":{"type":"boolean","description":"Submit Enter after text (default true). Set false for control characters or incomplete REPL input."},"run_in_background":{"type":"boolean","description":"Return a task id immediately; collect with task_output or stop with task_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730504660,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} {"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} {"type":"assistant/chunk","seq":12,"time":1785498587439,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":13,"time":1785730504661,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1785730504661,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8cad9650-de5a-4075-8aa3-1b35e67efc2d"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1785730504661,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"898077a7-5a40-4517-9eeb-bd840750776b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730504662,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","seq":16,"time":1785730504671,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"1ba1f641-0cf2-496c-895e-3982aa40b0ed"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730504671,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"ea4323d9-aa22-4a9c-a7cc-165c147ce624"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730504671,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730504679,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -23,9 +23,9 @@ {"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}} {"type":"assistant/chunk","seq":22,"time":1785498587457,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":23,"time":1785730504680,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1785730504680,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f5850535-906d-4a78-8518-a733ec91bbd8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"assistant/message","seq":24,"time":1785730504680,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"dc612d66-912d-4248-b95c-48675b127574"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1785730504680,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","seq":26,"time":1785730504688,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"4f19b0d0-b31d-40b7-84e5-2783131cb363"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"tool/result","seq":26,"time":1785730504688,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"fbcfc85b-a9fb-4421-81ec-f16f8ba772c4"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":27,"time":1785730504688,"data":{"turn":1,"step":2}} {"type":"step/start","seq":28,"time":1785730504696,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -33,9 +33,9 @@ {"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}} {"type":"assistant/chunk","seq":32,"time":1785498587473,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":33,"time":1785730504697,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":34,"time":1785730504697,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4e0d8eed-877d-4a5b-bd92-e7c4c8f2cf23"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"assistant/message","seq":34,"time":1785730504697,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c023922c-a5a6-4dd4-bace-578f30e453f5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} {"type":"tool/call","seq":35,"time":1785730504697,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","seq":36,"time":1785730504704,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"72be20be-6022-4363-b9bb-1d9f2cce0e20"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"tool/result","seq":36,"time":1785730504704,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"b68e7f58-8151-4a5c-974b-2ec5dc11186f"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"step/end","seq":37,"time":1785730504704,"data":{"turn":1,"step":3}} {"type":"step/start","seq":38,"time":1785730504712,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -43,9 +43,9 @@ {"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}} {"type":"assistant/chunk","seq":42,"time":1785498587489,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":43,"time":1785730504713,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":44,"time":1785730504713,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"87c2857f-5da3-4050-b081-e044e207be88"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} +{"type":"assistant/message","seq":44,"time":1785730504713,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3ce15df8-96d0-48a6-a1bd-908f779980bb"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} {"type":"tool/call","seq":45,"time":1785730504713,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","seq":46,"time":1785730504721,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"c6e3ff43-809c-4bd8-ba88-da7294c3385a"}},"sourceEventSeqs":[45],"surfaceOp":"append"} +{"type":"tool/result","seq":46,"time":1785730504721,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"2809ea63-1fa4-4878-b610-8e05ac36b294"}},"sourceEventSeqs":[45],"surfaceOp":"append"} {"type":"step/end","seq":47,"time":1785730504721,"data":{"turn":1,"step":4}} {"type":"step/start","seq":48,"time":1785730504730,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,9 +53,9 @@ {"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}}} {"type":"assistant/chunk","seq":52,"time":1785498587503,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":53,"time":1785730504731,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":54,"time":1785730504731,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5b53e0d5-1c68-4988-9fab-d885a9122fe8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} +{"type":"assistant/message","seq":54,"time":1785730504731,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5c7f9390-88ed-48cf-9ada-099320bac560"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} {"type":"tool/call","seq":55,"time":1785730504731,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","seq":56,"time":1785730504738,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"20e3fe4d-3d9d-4771-b75d-1f287fc20048"}},"sourceEventSeqs":[55],"surfaceOp":"append"} +{"type":"tool/result","seq":56,"time":1785730504738,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"d16c935f-465d-4dd8-882c-fe2dda09357c"}},"sourceEventSeqs":[55],"surfaceOp":"append"} {"type":"step/end","seq":57,"time":1785730504738,"data":{"turn":1,"step":5}} {"type":"step/start","seq":58,"time":1785730504746,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -63,9 +63,9 @@ {"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}}} {"type":"assistant/chunk","seq":62,"time":1785498587517,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":63,"time":1785730504747,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":64,"time":1785730504747,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"178fb9bc-859c-484d-8376-096a705de30a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} +{"type":"assistant/message","seq":64,"time":1785730504747,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b8603c4c-d8b7-4675-a8cd-7e5ea8b949a8"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} {"type":"tool/call","seq":65,"time":1785730504747,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","seq":66,"time":1785730504755,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"584673b3-9221-4a42-b9e4-69ce1b9f4d60"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"tool/result","seq":66,"time":1785730504755,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"47a7e409-fff1-4102-a4bb-be6bd6f7db53"}},"sourceEventSeqs":[65],"surfaceOp":"append"} {"type":"step/end","seq":67,"time":1785730504755,"data":{"turn":1,"step":6}} {"type":"step/start","seq":68,"time":1785730504763,"data":{"turn":1,"step":7}} {"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -73,6 +73,6 @@ {"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":72,"time":1785498587531,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} {"type":"assistant/chunk","seq":73,"time":1785730504764,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":74,"time":1785730504764,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"23d457ba-b690-4bb9-b434-86f43c9f4da5"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} +{"type":"assistant/message","seq":74,"time":1785730504764,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b6ccfcf3-5079-4c17-a361-610e1e588065"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} {"type":"step/end","seq":75,"time":1785730504764,"data":{"turn":1,"step":7}} {"type":"turn/end","seq":76,"time":1785730504764,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index f2cffb4010..031b23ae24 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -228,7 +228,9 @@ describe('ACP prompt lifecycle', () => { await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] })) .resolves.toEqual({ stopReason: 'end_turn' }) - await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') }) + // 'partial' is the cancelled turn's finalized prefix update; 'next' proves + // the second prompt settled independently of the aborted turn's late end. + await vi.waitFor(() => { expect(messageText(harness!)).toBe('partialnext') }) }) it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => { diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index 162f34d5ff..9b7f1f7c9f 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -283,7 +283,10 @@ function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] const request = index === undefined ? undefined : requests[index] updateAssistant(index, { completedAt: sourceEvent.time, - status: 'complete', + // A cancellation-finalized prefix is not a completed request: leave it + // running so the following step/end classifies it as before the prefix + // event existed. + status: sourceEvent.data.interrupted === true ? 'running' : 'complete', resultSeq: sourceEvent.seq, provenance: { provider: sourceEvent.data.message.source.provider, diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts index 031c2f4b45..5af1e1b3e6 100644 --- a/packages/client/runtime/tests/request-inspection.spec.ts +++ b/packages/client/runtime/tests/request-inspection.spec.ts @@ -85,6 +85,31 @@ describe('inspectRequests', () => { expect(snapshot.callSchemas.get('call-1')?.name).toBe('read') }) + it('leaves a cancellation-finalized prefix uncompleted so the step boundary classifies it', () => { + const events = [ + at(0, 'step/start', { turn: 1, step: 1 }), + at(1, 'request/header', { + reason: 'initial', + header: { config: { provider: 'fake', model: 'model' }, system: 'system' }, + }), + at(2, 'assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'cut short' }], + source: { provider: 'fake', model: 'model' }, + }), + interrupted: true, + }), + at(3, 'step/end', { turn: 1, step: 1 }), + at(4, 'turn/end', { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }), + ] + const snapshot = inspectRequests(entriesOf(events)) + expect(snapshot.requests).toMatchObject([ + { purpose: 'assistant', resultSeq: 2, status: 'error' }, + ]) + }) + it('does not promote a truncated resume or change header to the initial prompt', () => { for (const reason of ['resume', 'change'] as const) { const snapshot = inspectRequests(entriesOf([ diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 83df1e0df6..548ae54aca 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -162,6 +162,10 @@ function finalNode( firstTokenTime: state.firstTokenTime ?? null, completedTime: event.time, }, + // A cancellation-finalized prefix keeps its truncation marker: the + // durable event carries the classification, so the settled node still + // renders as interrupted (Stopped chip) rather than a completed answer. + ...event.data.interrupted === true ? { interrupted: true } : {}, } } const location = context.start?.location ?? context.matches.at(-1)?.location diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts index db9c0ccdd4..3697ac44a2 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.spec.ts @@ -158,6 +158,22 @@ describe('built-in conversation node Definitions', () => { expect(interrupted?.data).toMatchObject({ status: 'interrupted' }) expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true) + // A cancellation-finalized prefix event keeps its durable truncation + // marker through the settled projection. + const markedValue = assembler([ + at(20, 'turn/start', { turn: 3 }), + at(21, 'step/start', { turn: 3, step: 1 }), + at(22, 'assistant/message', { + turn: 3, + step: 1, + message: assistantMessage('assistant-3', 'cut short'), + interrupted: true, + }, { surfaceOp: 'append' }), + ]) + const marked = node(snapshot(markedValue), 'assistant-step') + expect(marked?.data).toMatchObject({ status: 'interrupted', blocks: [{ kind: 'text', text: 'cut short' }] }) + expect((marked?.data as AssistantChatData).finalNode?.interrupted).toBe(true) + const hiddenValue = assembler([ at(20, 'turn/start', { turn: 3 }), at(21, 'step/start', { turn: 3, step: 1 }), diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index c3fb9737b8..aeda41eb59 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -367,6 +367,11 @@ export class ReactLoopAgent implements Agent { signal.throwIfAborted() const finish = assembler.finish if (finish.kind === 'error' || finish.kind === 'aborted') { + // A failed attempt is never finalizable: provider failures commit + // nothing, and a cancel landing during recovery (typically the + // llm/retry backoff, after clients reset the streamed rendering) + // must not resurrect the failed stream's prefix. + attempt = undefined const action = await this.dispatch.waterfall( 'agent/request-error', { turn, @@ -382,7 +387,6 @@ export class ReactLoopAgent implements Agent { if (action?.kind !== 'retry') { throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) } - attempt = undefined continue } @@ -444,6 +448,7 @@ export class ReactLoopAgent implements Agent { turn, step, message, + interrupted: true, ...attempt.assembler.usage === undefined ? {} : { usage: attempt.assembler.usage }, }, { surfaceOp: 'append', sourceEventSeqs: attempt.chunkSeqs }, diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index f8ecc4680f..dabc30ed2f 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -490,10 +490,12 @@ describe('Agent.cancel()', () => { await waitForIdle(ctx, agent) // The prefix the user watched stream is committed as the step's message, - // citing exactly the chunk events that delivered it. + // carrying the truncation marker and citing exactly the chunk events that + // delivered it. const message = agent.session.events.find(e => e.type === 'assistant/message') expect(message?.type === 'assistant/message' ? message.data.message.content : undefined) .toEqual([{ type: 'text', text: 'partial' }]) + expect(message?.type === 'assistant/message' ? message.data.interrupted : undefined).toBe(true) const chunkSeqs = agent.session.events.filter(e => e.type === 'assistant/chunk').map(e => e.seq) expect(message?.sourceEventSeqs).toEqual(chunkSeqs) const types = agent.session.events.map(e => e.type) @@ -559,6 +561,61 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false) }) + it('cancel during error recovery does not finalize the failed stream', async () => { + const adapter = new MockAdapter([[ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'doomed partial' }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'SERVER_ERROR' } } }, + ]]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('recovery-cancel'), { provider: 'mock', model: 'mock' }) + // Cancellation lands while agent/request-error is in flight — the window + // dsh-llm-retry opens when its backoff waits after appending llm/retry. + ctx.on('agent/request-error', async ({ agent: subject }) => { + if (subject === agent) subject.cancel({ kind: 'user' }) + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + // The failed stream's prefix stays off the surface: clients reset it on + // retry, and provider failures commit nothing. + expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false) + const end = agent.session.events.find(e => e.type === 'turn/end') + expect(end?.type === 'turn/end' ? end.data.reason.kind : undefined).toBe('aborted') + }) + + it('retry discards the failed attempt; the final message cites only its own chunks', async () => { + const adapter = new MockAdapter([ + [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'doomed partial' }, + { type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'SERVER_ERROR' } } }, + ], + textResponse('recovered'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('retry-discards-content'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/request-error', async () => ({ kind: 'retry' as const })) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + const messages = agent.session.events.filter(e => e.type === 'assistant/message') + expect(messages).toHaveLength(1) + const message = messages[0]! + expect(message.type === 'assistant/message' ? message.data.message.content : undefined) + .toEqual([{ type: 'text', text: 'recovered' }]) + expect(message.type === 'assistant/message' ? message.data.interrupted : undefined).toBeUndefined() + // The abandoned attempt's chunks stay out of the completion's source set. + const doomedSeqs = agent.session.events + .filter(e => e.type === 'assistant/chunk' + && e.data.chunk.type === 'text-delta' && e.data.chunk.text === 'doomed partial') + .map(e => e.seq) + expect(doomedSeqs).toHaveLength(1) + expect(message.sourceEventSeqs).not.toContain(doomedSeqs[0]) + }) + it('cancel before any visible content finalizes nothing', async () => { const adapter = new MockAdapter([{ hangAfter: [ diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 22468b62c4..a94d4618f7 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -250,10 +250,11 @@ export interface SessionEventMap { * the model output and its accounting travel together (there is no separate * usage record). `usage` is absent when the adapter reported none. A turn * cancelled mid-stream finalizes its delivered text/reasoning prefix as this - * event (undispatched tool calls are absent); an aborted turn with no such - * event streamed no visible content. + * event with `interrupted: true` (undispatched tool calls are absent), so + * consumers classify the truncation without re-deriving it from turn + * boundaries; an aborted turn with no such event streamed no visible content. */ - 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage } + 'assistant/message': { turn: number; step: number; message: AssistantMessage; usage?: TokenUsage; interrupted?: true } /** * The model requested one tool invocation: `name` with the raw `arguments` * JSON string exactly as the model produced it (unparsed). `callId` pairs the diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index d65a1bc796..e7b2bf0292 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -27,7 +27,8 @@ interface PartialBlock { * {@link ContentBlock}s and a final assistant {@link Message}. * * The agent loop feeds it while logging raw chunks for replay fidelity, then - * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends, + * or `interruptedBlocks()` when cancellation cut the stream short. * * Tolerant of delta-only protocols (no block-start/end); deltas arriving for * an index already closed by `block-end` are ignored (malformed stream) so a diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 3543d0fc98..f93f630caf 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -2583,7 +2583,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventMap', - declaration: '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}', + declaration: '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 interrupted?: true;\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}', }, { name: 'SessionEventMetadataFilter', From da403d60863f6bd2800168a5ed656e6cb5fb705e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 11 Aug 2026 23:58:38 +0800 Subject: [PATCH 007/137] feat(subprocess): Windows terminal inspection and signalling createProcessInspector now returns a WindowsProcessInspector on win32 instead of throwing: Toolhelp32 tree enumeration with GetProcessTimes start identities, the shell pid as a pseudo foreground group, taskkill tree signalling, and inspector-verified Windows teardown (node-pty signal kills throw on Windows, and externally taskkilled shells may never fire its exit notification, so the handle settles \done\ from the verified absence). subprocess-local and pty-local suites now run on Windows with platform gates; the koffi-backed inspector joins the windows-only coverage exclusions on Linux and is fully covered by the windows-native lane. Also flips vitest.config so subprocess-local and pty-local sources are coverage-required on win32, and adapts the spawn/terminal suites to run natively there (node-translated shell commands, injected POSIX group paths, taskkill signal semantics). --- .../subprocess/subprocess-local/package.json | 1 + .../subprocess-local/src/process-inspector.ts | 2 + .../subprocess-local/src/terminal.ts | 74 +++++ .../subprocess-local/src/windows-inspector.ts | 288 ++++++++++++++++++ .../subprocess-local/tests/local.spec.ts | 17 +- .../tests/process-inspector.spec.ts | 6 +- .../subprocess-local/tests/spawn.spec.ts | 177 +++++++++-- .../subprocess-local/tests/terminal.spec.ts | 127 +++++++- .../tests/windows-inspector.spec.ts | 142 +++++++++ pnpm-lock.yaml | 6 + vitest.config.ts | 15 +- 11 files changed, 809 insertions(+), 46 deletions(-) create mode 100644 packages/subprocess/subprocess-local/src/windows-inspector.ts create mode 100644 packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 12f47c0400..ad89719667 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "koffi": "^3.1.0", "node-pty": "^1.1.0" }, "devDependencies": { diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index f31de010de..89effc0082 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -3,6 +3,7 @@ import { closeSync, openSync, readFileSync, readdirSync, readSync } from 'node:fs' import { execFileSync } from 'node:child_process' import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' +import { createWindowsProcessInspector } from './windows-inspector.ts' /** PID plus start identity, preventing teardown escalation after PID reuse. */ export interface ProcessIdentity { @@ -370,5 +371,6 @@ export function createProcessInspector( ): ProcessInspector { if (platform === 'linux') return new LinuxProcessInspector(arch, internals) if (platform === 'darwin') return new MacProcessInspector(internals) + if (platform === 'win32') return createWindowsProcessInspector() throw new Error(`subprocess-local: terminal inspection is unsupported on platform ${platform}`) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 11d13a405a..6d3b981169 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -50,11 +50,13 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { * @param terminal - allocated node-pty process. * @param inspector - platform process/session operations. * @param graceMs - TERM-to-KILL and exit-wait grace. + * @param platform - host platform; defaults to the running platform, injectable for deterministic tests. */ constructor( private readonly terminal: IPty, private readonly inspector: ProcessInspector, private readonly graceMs: number, + private readonly platform: NodeJS.Platform = process.platform, ) { this.pid = terminal.pid this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) @@ -98,6 +100,19 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) { throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead') } + if (this.platform === 'win32') { + if (signal === 'SIGINT') { + // Windows has no process-group signalling: a `\x03` input write is the + // Ctrl-C delivery path conhost turns into a console-wide CTRL_C event + // for attached processes. node-pty's signal kills throw on Windows, so + // no signal ever reaches the inspector. + this.terminal.write('\x03') + return foreground.processGroupId + } + if (signal === 'SIGTSTP' || signal === 'SIGHUP') { + throw new Error(`signal ${signal} is unsupported on Windows; only SIGINT, SIGTERM, and SIGKILL are available`) + } + } this.inspector.signalGroup(foreground.processGroupId, signal) return foreground.processGroupId } @@ -177,6 +192,10 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } private async stopShell(): Promise { + if (this.platform === 'win32') { + await this.stopShellWindows() + return + } if (!this.exited) { try { this.terminal.kill('SIGTERM') @@ -196,6 +215,44 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (!this.exited) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) } + private async stopShellWindows(): Promise { + // node-pty's Windows kill(signal) throws ("Signals not supported on + // windows"), and its bare kill() delegates to a console-list agent that + // fails when the parent has no console. taskkill tree escalation is the + // teardown path, fenced on the shell's start identity like every + // descendant; a root identity miss falls back to the bare kill. taskkill + // termination also does not reliably fire node-pty's exit notification + // (the same console-list agent), so the tiers verify the shell's absence + // through the inspector instead of waiting on `done` alone. + const shellGone = (): boolean => + this.exited || (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) + if (!shellGone() && this.rootIdentity !== undefined) { + this.inspector.signalProcess(this.rootIdentity, 'SIGTERM') + await this.waitForWindowsShellExit() + } + if (!shellGone() && this.rootIdentity === undefined) { + try { + this.terminal.kill() + } catch (_topLevelAlreadyExitedDuringKill) { + // The exit callback is authoritative. + } + await Promise.race([this.done.then(() => undefined), delay(this.graceMs)]) + } + if (!shellGone() && this.rootIdentity !== undefined) { + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') + await this.waitForWindowsShellExit() + } + if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) + } + + private async waitForWindowsShellExit(): Promise { + const until = Date.now() + this.graceMs + while (!this.exited && Date.now() < until) { + if (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) return + await delay(Math.min(25, Math.max(1, until - Date.now()))) + } + } + private async closeOnce(): Promise { let survivors = await this.stopDescendants() if (survivors.length > 0) { @@ -206,7 +263,24 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (survivors.length > 0) { throw new Error(`terminal cleanup failed; surviving pids: ${survivors.map(member => member.pid).join(', ')}`) } + this.settleExitIfGone() this.dataDisposable.dispose() this.exitDisposable.dispose() } + + private settleExitIfGone(): void { + // An externally taskkilled Windows shell may never fire node-pty's exit + // notification (its console-list agent fails without a parent console), + // which would leave `done` — and every consumer awaiting it — unsettled + // forever. Teardown has just verified the shell's absence through the + // inspector, so a missing exit event is itself the outcome. + if (this.platform !== 'win32') return + if (this.exited) return + /* v8 ignore next -- stopShellWindows() verified the shell is gone or threw; + the identity re-check is a defensive fence for a future caller. */ + if (this.rootIdentity !== undefined && this.inspector.isAlive(this.rootIdentity)) return + this.exited = true + this.output.end() + this.outcome.resolve({ exitCode: null, signal: null }) + } } diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts new file mode 100644 index 0000000000..78bea583d8 --- /dev/null +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -0,0 +1,288 @@ +/** + * Windows process-table operations for terminal readiness, signalling, and + * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time + * identity, the shell pid as a pseudo process group (Windows has no POSIX + * groups), and taskkill tree signalling. The koffi bindings load lazily so + * non-Windows processes never touch Win32 libraries; all decision logic takes + * an injectable internals boundary so suites can pin it on any host. + * @module dsh-subprocess-local/windows-inspector + */ + +import { spawnSync } from 'node:child_process' +import koffi from 'koffi' +import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' +import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts' + +/** One Toolhelp32 process-table row. */ +export interface ProcessEntry { + pid: number + parentPid: number +} + +/** Injectable Windows process operations used by one local PTY session. */ +export interface WindowsProcessInspectorInternals { + /** Enumerate the current process table (pid/parent pairs). */ + snapshot(): ProcessEntry[] + /** Return one process's creation-time identity, or undefined when unreadable. */ + creationTime(pid: number): string | undefined + /** Terminate one process tree; `force` maps to taskkill `/F`. */ + taskkill(pid: number, force: boolean): void +} + +/** + * Walk a process table from one root in children-first order, retaining only + * members whose start identity is readable (unreadable members are detector + * misses, exactly like an unreadable `/proc` entry on Linux). + * @param entries - the process table snapshot. + * @param rootPid - the tree root to descend from. + * @param started - creation-time identity resolver for one member. + * @returns the root and its current transitive descendants, children first. + */ +export function windowsProcessTree( + entries: ProcessEntry[], + rootPid: number, + started: (pid: number) => string | undefined, +): ProcessIdentity[] { + const byPid = new Map(entries.map(entry => [entry.pid, entry])) + const root = byPid.get(rootPid) + if (root === undefined) return [] + const byParent = new Map() + for (const entry of entries) { + const children = byParent.get(entry.parentPid) ?? [] + children.push(entry) + byParent.set(entry.parentPid, children) + } + const visited = new Set() + const result: ProcessIdentity[] = [] + const visit = (entry: ProcessEntry): void => { + if (visited.has(entry.pid)) return + visited.add(entry.pid) + for (const child of byParent.get(entry.pid) ?? []) visit(child) + const identity = started(entry.pid) + if (identity !== undefined) result.push({ pid: entry.pid, started: identity }) + } + visit(root) + return result +} + +/** + * Windows {@link ProcessInspector}. The shell pid stands in for a foreground + * process group: it is a stable pseudo-group that lets the prompt-marker + * readiness path compare foreground identities, while every actual signal + * targets the console-wide tree through taskkill (SIGINT is delivered by the + * terminal handle as a `\x03` input write and never reaches this layer). + */ +export class WindowsProcessInspector implements ProcessInspector { + constructor( + private readonly internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(), + ) {} + + foregroundPgid(shellPid: number): number { + return shellPid + } + + isStdinWaiting(_pgid: number): boolean { + return false + } + + processTree(rootPid: number): ProcessIdentity[] { + return windowsProcessTree(this.internals.snapshot(), rootPid, this.internals.creationTime) + } + + processSession(_sessionId: number): ProcessIdentity[] { + return [] + } + + isAlive(identity: ProcessIdentity): boolean { + const started = this.internals.creationTime(identity.pid) + return started !== undefined && started === identity.started + } + + signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { + this.internals.taskkill(pgid, signal === 'SIGKILL') + } + + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void { + if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') + } +} + +/** + * Create the Windows process inspector. + * @param internals - injectable process operations; defaults to the koffi-backed table. + * @returns the Windows inspector. + */ +export function createWindowsProcessInspector( + internals: WindowsProcessInspectorInternals = defaultWindowsProcessInternals(), +): WindowsProcessInspector { + return new WindowsProcessInspector(internals) +} + +/** Terminate one Windows process tree with taskkill, contained like POSIX group signalling. */ +function taskkillTree(pid: number, force: boolean): void { + if (pid <= 0) return + // Outcome deliberately unchecked: an already-absent tree, exit races, and a + // missing taskkill binary are as tolerable here as ESRCH is for POSIX. + spawnSync('taskkill', ['/PID', String(pid), '/T', ...(force ? ['/F'] : [])], { stdio: 'ignore' }) +} + +declare const nativePtr: unique symbol +/** Koffi 3 native pointer (a BigInt address), branded so it cannot silently enter numeric contexts. */ +export type NativePtr = bigint & { readonly [nativePtr]: true } + +/** + * True for NULL and INVALID_HANDLE_VALUE returns from Win32 handle APIs. + * @param value - a handle as koffi may hand it back (pointer, null, or 0n). + * @returns whether the value signals an invalid handle. + */ +export function isInvalidHandle(value: NativePtr | null | undefined): boolean { + if (value === null || value === undefined) return true + const asBigInt = value as bigint + return asBigInt === 0n || asBigInt === 0xFFFFFFFFFFFFFFFFn || asBigInt === -1n +} + +/** The lazy koffi binding table: every Win32 call the Windows inspector uses. */ +interface Win32Bindings { + createToolhelp32Snapshot(flags: number, processId: number): NativePtr + process32FirstW(snapshot: NativePtr, entry: NativePtr): number + process32NextW(snapshot: NativePtr, entry: NativePtr): number + openProcess(desiredAccess: number, inheritHandle: number, pid: number): NativePtr + getProcessTimes( + process: NativePtr, + creation: NativePtr, + exit: NativePtr, + kernel: NativePtr, + user: NativePtr, + ): number + closeHandle(handle: NativePtr): number +} + +const PVOID: ReturnType = koffi.pointer('void') + +/** + * Resolve the koffi Win32 struct types once. Registration is lazy and cached + * because koffi's type registry is global per process: test runners that + * re-evaluate this module (a hoisted `vi.mock` re-imports the graph) must not + * re-register the names. + */ +function win32Structs(): { PROCESSENTRY32W: ReturnType; FILETIME: ReturnType } { + if (cachedStructs !== undefined) return cachedStructs + // koffi PROCESSENTRY32W layout (tlhelp32.h); the size assert pins the x64 layout. + const PROCESSENTRY32W = koffi.struct('PROCESSENTRY32W', { + dwSize: 'uint32', + cntUsage: 'uint32', + th32ProcessID: 'uint32', + th32DefaultHeapID: PVOID, + th32ModuleID: 'uint32', + cCntThreads: 'uint32', + th32ParentProcessID: 'uint32', + pcPriClassBase: 'int32', + dwFlags: 'uint32', + szExeFile: koffi.array('char16', 260), + }) + // koffi FILETIME layout (minwinbase.h): two 32-bit halves of the 64-bit timestamp. + const FILETIME = koffi.struct('FILETIME', { + dwLowDateTime: 'uint32', + dwHighDateTime: 'uint32', + }) + /* v8 ignore start -- a layout-mismatch guard fires only on ABI breakage; the windows-native suites exercise the real struct. */ + if (PROCESSENTRY32W.size !== 568) { + throw new Error(`PROCESSENTRY32W layout mismatch: koffi computed ${PROCESSENTRY32W.size}, Windows headers say 568`) + } + /* v8 ignore stop */ + cachedStructs = { PROCESSENTRY32W, FILETIME } + return cachedStructs +} + +let cachedStructs: ReturnType | undefined + +const TH32CS_SNAPPROCESS = 0x2 +const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + +let cachedBindings: Win32Bindings | undefined + +/** + * Resolve the lazy Win32 bindings (throws the first binding failure, fail-closed). + * @returns the cached binding table. + */ +function win32Bindings(): Win32Bindings { + if (cachedBindings !== undefined) return cachedBindings + const { PROCESSENTRY32W, FILETIME } = win32Structs() + const kernel32 = koffi.load('kernel32.dll') + const bind = ( + name: string, + result: ReturnType | string, + args: Array | string>, + ): unknown => kernel32.func('__stdcall', name, result, args) + cachedBindings = { + createToolhelp32Snapshot: bind('CreateToolhelp32Snapshot', PVOID, ['uint32', 'uint32']), + process32FirstW: bind('Process32FirstW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]), + process32NextW: bind('Process32NextW', 'int', [PVOID, koffi.pointer(PROCESSENTRY32W)]), + openProcess: bind('OpenProcess', PVOID, ['uint32', 'int', 'uint32']), + getProcessTimes: bind('GetProcessTimes', 'int', [ + PVOID, + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + koffi.pointer(FILETIME), + ]), + closeHandle: bind('CloseHandle', 'int', [PVOID]), + } as unknown as Win32Bindings + return cachedBindings +} + +/** Enumerate the current process table through Toolhelp32. */ +function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { + const { PROCESSENTRY32W } = win32Structs() + const snapshot = bindings.createToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + /* v8 ignore next -- an invalid snapshot for the process flag is not producible through the public API; + the guard mirrors POSIX's unreadable-proc tolerance and isInvalidHandle is unit-tested. */ + if (isInvalidHandle(snapshot)) return [] + const entries: ProcessEntry[] = [] + try { + const entry = koffi.alloc(PROCESSENTRY32W, 1) + koffi.encode(entry, 'uint32', PROCESSENTRY32W.size) + let ok = bindings.process32FirstW(snapshot, entry) + while (ok !== 0) { + const record = koffi.decode(entry, PROCESSENTRY32W) as { + th32ProcessID: number + th32ParentProcessID: number + } + entries.push({ pid: record.th32ProcessID, parentPid: record.th32ParentProcessID }) + ok = bindings.process32NextW(snapshot, entry) + } + } finally { + bindings.closeHandle(snapshot) + } + return entries +} + +/** Read one process's creation-time identity through GetProcessTimes. */ +function windowsCreationTime(bindings: Win32Bindings, pid: number): string | undefined { + const { FILETIME } = win32Structs() + const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) + if (isInvalidHandle(handle)) return undefined + try { + const creation = koffi.alloc(FILETIME, 1) + const exit = koffi.alloc(FILETIME, 1) + const kernel = koffi.alloc(FILETIME, 1) + const user = koffi.alloc(FILETIME, 1) + /* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and + cannot be staged deterministically; the absent-process path is covered and the caller + treats undefined as a detector miss. */ + if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined + const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number } + return `${record.dwHighDateTime}:${record.dwLowDateTime}` + } finally { + bindings.closeHandle(handle) + } +} + +/** The koffi-backed default internals; bindings resolve lazily on first use. */ +function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals { + return { + snapshot: () => snapshotWindowsProcesses(win32Bindings()), + creationTime: pid => windowsCreationTime(win32Bindings(), pid), + taskkill: taskkillTree, + } +} diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index e3131543f4..88f1b351fb 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -7,8 +7,16 @@ import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalS import { childEnv } from '../src/spawn.ts' function spec(command: string, overrides: Partial = {}): SubprocessSpawnSpec { + // Windows has no bash; the suite's simple commands translate to node one-liners. + const argv = process.platform === 'win32' + ? [process.execPath, '-e', { + 'echo managed': 'console.log("managed")', + 'sleep 60': 'setTimeout(() => {}, 60000)', + 'true': '', + }[command] ?? command] + : ['bash', '-c', command] return { - argv: ['bash', '-c', command], + argv, cwd: process.cwd(), stdio: { stdin: 'ignore', @@ -60,9 +68,9 @@ describe('LocalSubprocessService', () => { const explicit = childEnv({ Path: '/bin', PathExt: '.EXE;.CMD' }) expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATH')).toEqual(['Path']) expect(Object.keys(explicit).filter(key => key.toUpperCase() === 'PATHEXT')).toEqual(['PathExt']) - expect(candidates('tool', explicit)).toEqual(['/bin/tool.EXE', '/bin/tool.CMD']) + expect(candidates('tool', explicit)).toEqual([resolve('/bin', 'tool.EXE'), resolve('/bin', 'tool.CMD')]) expect(candidates('tool', { Path: '/ambient', PATH: '/explicit', PATHEXT: '.EXE' })) - .toEqual(['/explicit/tool.EXE']) + .toEqual([resolve('/explicit', 'tool.EXE')]) expect(candidates('tool.exe', {})).toEqual([resolve(process.cwd(), 'tool.exe')]) expect(candidates('tool', { PATH: '/bin' })).toHaveLength(4) await expect(ctx.subprocess.resolveExecutable(String.raw`bin\server.exe`)) @@ -286,7 +294,8 @@ describe('LocalSubprocessService', () => { const handle = ctx.subprocess.spawn(spec('sleep 60')) await fiber.dispose() const outcome = await handle.done - expect(outcome.signal).toBe('SIGTERM') + // Windows teardown terminates through taskkill, which reports no signal. + expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) it('a settled process leaves the live set (disposal does not re-kill it)', async () => { diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index c90a7b3490..aadf2e1388 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -5,6 +5,7 @@ import { parseProcStat, } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' +import { WindowsProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string { const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)] @@ -237,12 +238,13 @@ describe('macOS process inspector', () => { ]) }) - it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => { + it('returns undefined for missing or invalid foreground groups and dispatches platform inspectors', () => { const fake = fakeInternals() fake.setTpgid('-1') expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined() fake.internals.exec = () => { throw new Error('gone') } expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined() - expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported on platform win32') + expect(createProcessInspector('win32', 'x64', fake.internals)).toBeInstanceOf(WindowsProcessInspector) + expect(() => createProcessInspector('freebsd', 'x64', fake.internals)).toThrow('unsupported on platform freebsd') }) }) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 4cffde6432..d0fcc422fb 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { + childEnv, killGroup, OutputCollector, spawnSubprocess, @@ -11,6 +12,49 @@ import { import type { SubprocessHandle, SubprocessOutputReader } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +/** + * Translate the suite's POSIX command strings into node one-liners on Windows, + * where no bash exists; the translated commands keep the same observable + * stdout/stderr/exit-code contract the bash originals pin on POSIX. + * @param command - the bash `-c` command string used by the test. + * @returns the argv to spawn. + */ +function shellArgv(command: string): string[] { + if (process.platform !== 'win32') return ['bash', '-c', command] + const node = (script: string): string[] => [process.execPath, '-e', script] + switch (command) { + case 'true': return node('') + case 'echo hello': return node('console.log("hello")') + case 'echo hi': return node('console.log("hi")') + case 'echo oops >&2': return node('console.error("oops")') + case 'echo err >&2': return node('console.error("err")') + case 'echo out; echo err >&2': return node('console.log("out"); console.error("err")') + case 'echo out; echo to-parent >&2': return node('console.log("out"); console.error("to-parent")') + case 'echo to-parent; echo err >&2': return node('console.log("to-parent"); console.error("err")') + case 'exit 42': return node('process.exit(42)') + case 'exit 7': return node('process.exit(7)') + case 'pwd': return node('console.log(process.cwd())') + case 'sleep 60': return node('setTimeout(() => {}, 60000)') + case 'cat': return node('process.stdin.pipe(process.stdout)') + case 'unused': return node('') + case 'echo "${TERM:-unset}"': return node('console.log(process.env.TERM ?? "unset")') + case 'echo "$EXTRA_ONE/$EXTRA_TWO"': return node('console.log(process.env.EXTRA_ONE + "/" + process.env.EXTRA_TWO)') + case 'echo "$EXPLICIT_OVERRIDE_PASSWORD"': return node('console.log(process.env.EXPLICIT_OVERRIDE_PASSWORD)') + case 'echo "${SUBPROCESS_TOMBSTONE_PROBE:-absent}"': return node('console.log(process.env.SUBPROCESS_TOMBSTONE_PROBE ?? "absent")') + case 'echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"': + return node('console.log("[" + [process.env.DSH_STALE ?? "absent", process.env.DSH_SHELL, process.env.DSH_SESSION_ID].join("|") + "]")') + case 'echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${SUBPROCESS_TEST_PASSWORD:-absent}|${DSH_TEST_PLAIN:-absent}]"': + return node('console.log("[" + [process.env.DSH_TEST_API_KEY ?? "absent", process.env.DSH_TEST_TOKEN ?? "absent", process.env.SUBPROCESS_TEST_PASSWORD ?? "absent", process.env.DSH_TEST_PLAIN ?? "absent"].join("|") + "]")') + case 'printf "%.0sx" $(seq 1 500)': return node('process.stdout.write("x".repeat(500))') + case 'printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2': + return node('process.stdout.write("x".repeat(500)); process.stderr.write("e".repeat(500))') + case 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done': + return node('for (let i = 1; i <= 200; i++) console.log("line-" + String(i).padStart(4, "0"))') + default: + throw new Error(`spawn.spec: no win32 node translation for ${JSON.stringify(command)}`) + } +} + const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ failNextClose: { value: false }, failNextUnlink: { value: false }, @@ -48,7 +92,7 @@ type SpecOverrides = Partial[0]> & { function spec(command: string, overrides: SpecOverrides = {}) { const { stdoutMaxBytes = 64_000, stderrMaxBytes = 64_000, maxSpillBytes = 64 * 1024 * 1024, stdin, ...rest } = overrides return { - argv: ['bash', '-c', command], + argv: shellArgv(command), cwd: process.cwd(), stdio: { stdin: stdin !== undefined ? { data: stdin } : 'ignore' as const, @@ -161,7 +205,7 @@ describe('spawnSubprocess', () => { expect(result.stdout.text).toBe('callers-choice\n') }) - it('runs in the requested cwd', async () => { + it.skipIf(process.platform === 'win32')('runs in the requested cwd', async () => { const result = await finish(spawnSubprocess(spec('pwd', { cwd: '/tmp' }))) expect(result.stdout.text.trim()).toMatch(/\/tmp$/) }) @@ -176,11 +220,12 @@ describe('spawnSubprocess', () => { setTimeout(() => { controller.abort('deadline') }, 100) const result = await running.done expect(Date.now() - start).toBeLessThan(5_000) - expect(result.signal).toBe('SIGTERM') - expect(result.exitCode).toBeNull() + // Windows teardown terminates through taskkill, which reports no signal. + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') + expect(result.exitCode).toBe(process.platform === 'win32' ? 1 : null) }) - it('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { + it.skipIf(process.platform === 'win32')('terminate() escalates to SIGKILL when SIGTERM is trapped', async () => { const running = spawnSubprocess(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) await waitForStdout(running, 'ready\n') running.terminate() @@ -231,12 +276,16 @@ describe('spawnSubprocess', () => { expect(forceSignals).toBe(0) } finally { killSpy.mockRestore() - process.kill(helper, 'SIGKILL') + try { + process.kill(helper, 'SIGKILL') + } catch { + // taskkill already took the helper down on Windows. + } await waitGone(helper) } }) - it('terminates the whole process group (grandchildren die too)', async () => { + it.skipIf(process.platform === 'win32')('terminates the whole process group (grandchildren die too)', async () => { // The subshell writes the sleep's pid then waits on it; terminating the // group must take the sleep down with bash. const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) @@ -255,7 +304,7 @@ describe('spawnSubprocess', () => { const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort('user cancelled') }, 50) const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) it('throws when the signal is already aborted before spawn', () => { @@ -275,10 +324,10 @@ describe('spawnSubprocess', () => { running.terminate() running.terminate() const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) - it('does not wait for a Linux group that has only zombie members', async () => { + it.skipIf(process.platform === 'win32')('does not wait for a Linux group that has only zombie members', async () => { const pidFile = join(spillDir, `zombie-group-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo leader-done`, { graceMs: 100 }), { platform: 'linux', @@ -296,7 +345,7 @@ describe('spawnSubprocess', () => { } }) - it('bounds inherited-pipe draining after the shell exits', async () => { + it.skipIf(process.platform === 'win32')('bounds inherited-pipe draining after the shell exits', async () => { const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) const started = Date.now() const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) @@ -328,7 +377,7 @@ describe('stdin and extra env (set by in-process plugins)', () => { expect(result.stdout.text).toBe('') }) - it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { + it.skipIf(process.platform === 'win32')('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { // With no bytes, fd 0 remains the pre-spawn `ignore` default (/dev/null, a character device). // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. const none = await finish(spawnSubprocess(spec('test -c /dev/stdin && echo char || echo other'))) @@ -600,7 +649,7 @@ describe('windows tree semantics (injected platform)', () => { running.terminate() const outcome = await running.done expect(killed).toContain(running.pid) - expect(outcome.signal).toBe('SIGKILL') + expect(outcome.signal).toBe(process.platform === 'win32' ? null : 'SIGKILL') }) it('waitForExit falls back to direct-child liveness where groups do not exist', async () => { @@ -611,7 +660,7 @@ describe('windows tree semantics (injected platform)', () => { }) describe('waitForExit', () => { - it('waits for the whole detached tree, not just the shell', async () => { + it.skipIf(process.platform === 'win32')('waits for the whole detached tree, not just the shell', async () => { const pidFile = join(spillDir, `tree-wait-${Date.now()}.pid`) const running = spawnSubprocess(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) const grandchild = await waitForPidFile(pidFile) @@ -631,7 +680,7 @@ describe('waitForExit', () => { }) }) -describe('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { +describe.skipIf(process.platform === 'win32')('tree-survivor escalation (terminate and bounded waits reach helpers the leader left behind)', () => { it('terminate() SIGKILLs a TERM-trapping descendant after the direct child settles', async () => { // The leader spawns a TERM-trapping helper with all stdio detached from // the collected pipes, then exits: the helper holds the GROUP alive while @@ -697,6 +746,94 @@ describe('coverage seams', () => { expect(() => { taskkillProcessTree(2 ** 30) }).not.toThrow() }) + it('covers the injected POSIX group paths on any host', async () => { + // Windows has no POSIX groups, so the tree-liveness probe, group + // signalling, and the SIGKILL escalation timer only run here through the + // injected platform; the mock keeps the group alive through TERM and + // terminates the direct child when the escalation tier delivers SIGKILL. + const running = spawnSubprocess(spec('sleep 60', { graceMs: 100 }), { + platform: 'linux', + linuxProcessGroupHasLiveMembers: () => false, + }) + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (typeof target === 'number' && target < 0) { + if (signal === 0) return true + if (signal === 'SIGKILL') realKill(running.pid, 'SIGKILL') + return true + } + return realKill(target, signal) + }) + try { + running.terminate() + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + } finally { + killSpy.mockRestore() + } + }) + + it('treats a vanished group probe as quiescent without signalling', async () => { + const running = spawnSubprocess(spec('sleep 60'), { platform: 'linux' }) + const realKill = process.kill.bind(process) + const killSpy = vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + if (typeof target === 'number' && target < 0) { + throw Object.assign(new Error('simulated absent group'), { code: 'ESRCH' }) + } + return realKill(target, signal) + }) + try { + running.terminate() + await new Promise(resolve => setTimeout(resolve, 20)) + realKill(running.pid, 'SIGKILL') + await running.done + await expect(running.waitForExit()).resolves.toBe(true) + } finally { + killSpy.mockRestore() + } + }) + + it('childEnv keeps the POSIX spread on non-Windows hosts', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + try { + expect(childEnv({ DSH_X: '1' }).DSH_X).toBe('1') + } finally { + platform.mockRestore() + } + }) + + it('settles through the pipe-drain timer when a descendant holds a collected pipe', async () => { + // The leader spawns a detached grandchild inheriting the collected stdout + // pipe, then exits: `close` cannot settle while the grandchild holds the + // pipe, so the bounded pipe-drain timer must settle the outcome. + const pidFile = join(spillDir, `pipe-drain-${Date.now()}.pid`) + const childScript = ` + const { spawn } = require('node:child_process') + const { writeFileSync } = require('node:fs') + const helper = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + detached: true, + stdio: ['ignore', 1, 2], + }) + writeFileSync(${JSON.stringify(pidFile)}, String(helper.pid)) + helper.unref() + ` + const running = spawnSubprocess({ + ...spec('unused', { graceMs: 100 }), + argv: [process.execPath, '-e', childScript], + }) + const helper = await waitForPidFile(pidFile) + const started = Date.now() + const outcome = await running.done + expect(outcome.exitCode).toBe(0) + expect(Date.now() - started).toBeGreaterThanOrEqual(90) + try { + process.kill(helper, 'SIGKILL') + } catch { + // Already gone; the drain bound is the point under test. + } + await waitGone(helper) + }) + it('a spawn-failed handle rejects done while waitForExit reports gone', async () => { const running = spawnSubprocess(spec('true', { cwd: '/nonexistent-dir-dsh-dispose-test' })) await expect(running.done).rejects.toThrow() @@ -833,7 +970,7 @@ describe('argv validation', () => { expect(() => spawnSubprocess({ ...spec('true'), argv: [''] })).toThrow(/non-empty program name/) }) - it('spawns argv verbatim without shell interpretation', async () => { + it.skipIf(process.platform === 'win32')('spawns argv verbatim without shell interpretation', async () => { const result = await finish(spawnSubprocess({ ...spec('unused'), argv: ['printf', '%s', '$HOME'] })) expect(result.stdout.text).toBe('$HOME') }) @@ -853,7 +990,7 @@ describe('abort edge cases', () => { .toThrow(/aborted before spawn: aborted/) }) - it('reports the terminating signal of an externally self-killed command', async () => { + it.skipIf(process.platform === 'win32')('reports the terminating signal of an externally self-killed command', async () => { // spawnSubprocess reports the raw signal; whether it counts as timeout/cancel is the // executor's classification (a self-kill is neither) — see executor.spec.ts. const result = await finish(spawnSubprocess(spec('kill -TERM $$'))) @@ -894,7 +1031,7 @@ describe('environment and spill-file hardening', () => { } }) - it('creates spill files with owner-only permissions and random names', async () => { + it.skipIf(process.platform === 'win32')('creates spill files with owner-only permissions and random names', async () => { const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), { spillDir }, @@ -905,7 +1042,7 @@ describe('environment and spill-file hardening', () => { expect(mode).toBe(0o600) }) - it('defaults spills into a private per-process directory', async () => { + it.skipIf(process.platform === 'win32')('defaults spills into a private per-process directory', async () => { const result = await finish(spawnSubprocess( spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), )) @@ -931,6 +1068,6 @@ describe('environment and spill-file hardening', () => { const running = spawnSubprocess(spec('sleep 60', { signal: controller.signal })) setTimeout(() => { controller.abort() }, 50) const result = await running.done - expect(result.signal).toBe('SIGTERM') + expect(result.signal).toBe(process.platform === 'win32' ? null : 'SIGTERM') }) }) diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 79501c7dc4..2622719867 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -73,6 +73,8 @@ class FakeInspector implements ProcessInspector { this.groups.push([pgid, signal]) } signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') { + // Mirrors the real inspectors' alive-gated signalling. + if (!this.alive.has(identity.pid)) return if (this.throwProcess) throw new Error('process raced') this.processes.push([identity.pid, signal]) if (this.removeOnSignal) this.alive.delete(identity.pid) @@ -81,12 +83,18 @@ class FakeInspector implements ProcessInspector { afterEach(() => { vi.useRealTimers() }) +function makeHandle(pty: FakePty, inspector: ProcessInspector, graceMs: number): LocalTerminalHandle { + // The suite pins POSIX signalling semantics deterministically on every host; + // the win32 branches get their own platform-explicit tests below. + return new LocalTerminalHandle(pty.asPty(), inspector, graceMs, 'linux') +} + describe('LocalTerminalHandle', () => { it('bridges terminal bytes, foreground control, and signalled exit facts', async () => { const pty = new FakePty() const inspector = new FakeInspector() inspector.waiting = true - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) const chunks: Buffer[] = [] handle.output.on('data', (chunk: Buffer) => { chunks.push(chunk) }) @@ -107,7 +115,7 @@ describe('LocalTerminalHandle', () => { it('rejects unsafe foreground signals and writes after exit', async () => { const pty = new FakePty() const inspector = new FakeInspector() - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) inspector.pgid = handle.pid await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session') inspector.pgid = undefined @@ -127,7 +135,7 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.removeOnSignal = false - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) const quiescent = handle.terminate() expect(handle.terminate()).toBe(quiescent) @@ -148,7 +156,7 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.removeOnSignal = false - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) pty.emitExit() const waiting = handle.terminate() let settled = false @@ -167,7 +175,7 @@ describe('LocalTerminalHandle', () => { const disowned = { pid: 124, started: 'disowned' } inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : [] inspector.alive.add(124) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) pty.emitExit() @@ -181,7 +189,7 @@ describe('LocalTerminalHandle', () => { const descendant = { pid: 124, started: 'observed' } inspector.members = [descendant] inspector.alive.add(descendant.pid) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) await handle.inspectForeground() inspector.members = [] @@ -194,7 +202,7 @@ describe('LocalTerminalHandle', () => { it('does not adopt the children of a recycled shell pid', async () => { const pty = new FakePty() const inspector = new FakeInspector() - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) pty.emitExit() const imposterChild = { pid: 999, started: 'imposter-child' } @@ -213,7 +221,7 @@ describe('LocalTerminalHandle', () => { const orphan = { pid: 321, started: 'unverifiable' } inspector.members = [orphan] inspector.alive.add(orphan.pid) - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() expect(inspector.processes).toEqual([]) @@ -238,7 +246,7 @@ describe('LocalTerminalHandle', () => { } return [] } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']]) expect(pty.kills).toEqual(['SIGTERM']) @@ -252,7 +260,7 @@ describe('LocalTerminalHandle', () => { inspector.sessionMembers = [late] inspector.alive.add(late.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) await handle.terminate() @@ -270,7 +278,7 @@ describe('LocalTerminalHandle', () => { inspector.sessionMembers = [late] inspector.alive.add(late.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + const handle = makeHandle(pty, inspector, 10) const first = handle.terminate() const failed = expect(first).rejects.toThrow('surviving pids: 124') @@ -297,7 +305,7 @@ describe('LocalTerminalHandle', () => { inspector.processes.push([identity.pid, signal]) if (signal === 'SIGKILL') inspector.alive.delete(identity.pid) } - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 20) + const handle = makeHandle(pty, inspector, 20) const quiescent = handle.terminate() await vi.advanceTimersByTimeAsync(25) await quiescent @@ -308,7 +316,7 @@ describe('LocalTerminalHandle', () => { vi.useFakeTimers() const pty = new FakePty() pty.autoExitOnKill = false - const handle = new LocalTerminalHandle(pty.asPty(), new FakeInspector(), 10) + const handle = makeHandle(pty, new FakeInspector(), 10) const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123') await vi.advanceTimersByTimeAsync(25) await failed @@ -326,7 +334,98 @@ describe('LocalTerminalHandle', () => { inspector.members = [{ pid: 124, started: 'child' }] inspector.alive.add(124) inspector.throwProcess = true - const handle = new LocalTerminalHandle(pty.asPty(), inspector, 1) + const handle = makeHandle(pty, inspector, 1) await expect(handle.terminate()).rejects.toThrow('surviving pids: 124') }) }) + +describe('LocalTerminalHandle on Windows', () => { + const win32 = 'win32' as NodeJS.Platform + + it('delivers SIGINT as a Ctrl-C input write without inspector signalling', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await expect(handle.signalForeground('SIGINT')).resolves.toBe(456) + expect(pty.writes).toEqual(['\x03']) + expect(inspector.groups).toEqual([]) + }) + + it('rejects SIGTSTP and SIGHUP as unavailable on Windows', async () => { + const handle = new LocalTerminalHandle(new FakePty().asPty(), new FakeInspector(), 10, win32) + await expect(handle.signalForeground('SIGTSTP')).rejects.toThrow('unsupported on Windows') + await expect(handle.signalForeground('SIGHUP')).rejects.toThrow('unsupported on Windows') + }) + + it('routes SIGTERM through the inspector tree with the pseudo foreground group', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await expect(handle.signalForeground('SIGTERM')).resolves.toBe(456) + expect(inspector.groups).toEqual([[456, 'SIGTERM']]) + expect(pty.writes).toEqual([]) + }) + + it('still refuses to SIGKILL the terminal shell on Windows', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + inspector.pgid = handle.pid + await expect(handle.signalForeground('SIGKILL')).rejects.toThrow('terminate the terminal session') + }) + + it('escalates the shell through taskkill tiers instead of node-pty signal kills', async () => { + vi.useFakeTimers() + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + const quiescent = handle.terminate() + await vi.advanceTimersByTimeAsync(5) + expect(inspector.processes).toEqual([[123, 'SIGTERM']]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + await quiescent + expect(inspector.processes).toEqual([[123, 'SIGTERM']]) + expect(pty.kills).toEqual([]) + }) + + it('reports a shell that survives both taskkill tiers', async () => { + vi.useFakeTimers() + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + inspector.removeOnSignal = false + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + const failed = expect(handle.terminate()).rejects.toThrow('surviving pid: 123') + await vi.advanceTimersByTimeAsync(25) + await failed + expect(inspector.processes).toEqual([[123, 'SIGTERM'], [123, 'SIGKILL']]) + expect(pty.kills).toEqual([]) + + pty.emitExit() + await handle.terminate() + }) + + it('skips taskkill escalation entirely when the shell already exited', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(123) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + pty.emitExit() + await handle.terminate() + expect(inspector.processes).toEqual([]) + expect(pty.kills).toEqual([]) + }) + + it('falls back to the bare node-pty kill when the shell identity was never observable', async () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.root = undefined + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10, win32) + await handle.terminate() + expect(pty.kills).toHaveLength(1) + expect(inspector.processes).toEqual([]) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts new file mode 100644 index 0000000000..b18945fc10 --- /dev/null +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { + createWindowsProcessInspector, + isInvalidHandle, + windowsProcessTree, + WindowsProcessInspector, +} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' +import type { + NativePtr, + ProcessEntry, + WindowsProcessInspectorInternals, +} from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' + +function fakeInternals() { + const entries: ProcessEntry[] = [] + const times = new Map() + const kills: Array<[number, boolean]> = [] + return { + internals: { + snapshot: () => [...entries], + creationTime: pid => times.get(pid), + taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) }, + } satisfies WindowsProcessInspectorInternals, + add(entry: ProcessEntry, started?: string): void { + entries.push(entry) + if (started !== undefined) times.set(entry.pid, started) + }, + kills, + } +} + +describe('windowsProcessTree', () => { + it('walks a table children-first with readable identities only', () => { + const started = (pid: number): string | undefined => pid === 12 ? undefined : `t${pid}` + expect(windowsProcessTree([ + { pid: 10, parentPid: 0 }, + { pid: 11, parentPid: 10 }, + { pid: 12, parentPid: 11 }, + { pid: 13, parentPid: 11 }, + { pid: 14, parentPid: 10 }, + ], 10, started)).toEqual([ + { pid: 13, started: 't13' }, + { pid: 11, started: 't11' }, + { pid: 14, started: 't14' }, + { pid: 10, started: 't10' }, + ]) + }) + + it('returns an empty walk for an absent root', () => { + expect(windowsProcessTree([{ pid: 10, parentPid: 0 }], 99, () => 't')).toEqual([]) + }) + + it('terminates on a parent cycle instead of recursing forever', () => { + const entries = [ + { pid: 10, parentPid: 11 }, + { pid: 11, parentPid: 10 }, + ] + expect(windowsProcessTree(entries, 10, () => 't')).toHaveLength(2) + }) +}) + +describe('WindowsProcessInspector (injected internals)', () => { + it('exposes the shell pid as the pseudo foreground group and never proves stdin waits', () => { + const fake = fakeInternals() + const inspector = new WindowsProcessInspector(fake.internals) + expect(inspector.foregroundPgid(77)).toBe(77) + expect(inspector.isStdinWaiting(77)).toBe(false) + expect(inspector.processSession(77)).toEqual([]) + }) + + it('delegates tree walks and identity checks to the internals', () => { + const fake = fakeInternals() + fake.add({ pid: 10, parentPid: 0 }, 't10') + fake.add({ pid: 11, parentPid: 10 }, 't11') + const inspector = new WindowsProcessInspector(fake.internals) + expect(inspector.processTree(10)).toEqual([ + { pid: 11, started: 't11' }, + { pid: 10, started: 't10' }, + ]) + expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true) + expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false) + expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false) + }) + + it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => { + const fake = fakeInternals() + const inspector = new WindowsProcessInspector(fake.internals) + inspector.signalGroup(77, 'SIGKILL') + inspector.signalGroup(77, 'SIGTERM') + inspector.signalGroup(0, 'SIGKILL') + expect(fake.kills).toEqual([[77, true], [77, false], [0, true]]) + }) + + it('signals a process only while its start identity matches', () => { + const fake = fakeInternals() + fake.add({ pid: 10, parentPid: 0 }, 't10') + const inspector = new WindowsProcessInspector(fake.internals) + inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL') + inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM') + expect(fake.kills).toEqual([[10, true]]) + }) + + it('accepts an injected internals factory through the creator', () => { + const fake = fakeInternals() + expect(createWindowsProcessInspector(fake.internals)).toBeInstanceOf(WindowsProcessInspector) + expect(createWindowsProcessInspector()).toBeInstanceOf(WindowsProcessInspector) + }) +}) + +describe('isInvalidHandle', () => { + it('rejects null, zero, and the all-ones INVALID_HANDLE_VALUE forms', () => { + const ptr = (value: bigint): NativePtr => value as NativePtr + expect(isInvalidHandle(null)).toBe(true) + expect(isInvalidHandle(undefined)).toBe(true) + expect(isInvalidHandle(ptr(0n))).toBe(true) + expect(isInvalidHandle(ptr(0xFFFFFFFFFFFFFFFFn))).toBe(true) + expect(isInvalidHandle(ptr(-1n))).toBe(true) + expect(isInvalidHandle(ptr(1234n))).toBe(false) + }) +}) + +const win32 = process.platform === 'win32' ? describe : describe.skip + +win32('WindowsProcessInspector over the real koffi bindings', () => { + it('walks the live process table from the test runner itself', () => { + const inspector = createWindowsProcessInspector() + const tree = inspector.processTree(process.pid) + const self = tree.find(member => member.pid === process.pid) + expect(self).toBeDefined() + expect(inspector.isAlive(self!)).toBe(true) + expect(inspector.foregroundPgid(process.pid)).toBe(process.pid) + }) + + it('reports unreadable identities for absent processes and no-ops tree signalling', () => { + const inspector = createWindowsProcessInspector() + expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) + expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGKILL')).not.toThrow() + expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGTERM')).not.toThrow() + expect(() => inspector.signalGroup(0, 'SIGKILL')).not.toThrow() + expect(() => inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL')).not.toThrow() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e890bf58c..254839878b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5478,6 +5478,9 @@ importers: packages/pty/pty-local: dependencies: + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -7085,6 +7088,9 @@ importers: packages/subprocess/subprocess-local: dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 node-pty: specifier: ^1.1.0 version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) diff --git a/vitest.config.ts b/vitest.config.ts index c698057915..b730be54a1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,23 +25,26 @@ const windowsUnsupportedPackages = process.platform === 'win32' // INCLUDED: PowerShell ships with Windows, so they run natively here. // This explicit list (not a 'packages/bash/*' glob) keeps // packages/bash/bash — the Service Definition package — running on Windows. + // subprocess-local and pty-local are NOT listed: their win32 branches are + // first-class (Windows inspector, pwsh dialect), so their suites run and + // their sources stay coverage-required on the windows-native lane; the + // bash-requiring tests inside them self-skip through hasBash probes. 'packages/bash/bash-local', 'packages/bash/bash-sandbox', 'packages/bash/tool-bash', 'packages/hooks/*', - 'packages/subprocess/*', - 'packages/pty/pty-local', 'packages/sandbox/sandbox-local', ] : [] -// Windows-only packages: their sources execute exclusively on win32 (koffi -// loads Win32 libraries), so the Linux coverage lane can never cover them. -// The Windows dev/CI lane exercises them through the probe/runner suites; the -// per-file 100% gate must not fail on their Linux-uncovered paths. +// Windows-only sources: they execute exclusively on win32 (koffi loads Win32 +// libraries), so the Linux coverage lane can never cover them. The Windows +// dev/CI lane exercises them through the probe/runner suites; the per-file +// 100% gate must not fail on their Linux-uncovered paths. const windowsOnlyCoverageExclusions = process.platform !== 'win32' ? [ 'packages/sandbox/sandbox-windows-acl/src/**/*.ts', + 'packages/subprocess/subprocess-local/src/windows-inspector.ts', ] : [] From 557c21cd6cc9bd917e786004d3d9fe701b8c845b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:06:42 +0800 Subject: [PATCH 008/137] feat(pty-local): shell dialect for Windows pwsh sessions Adds shellDialect ('bash' | 'pwsh') to the local PTY backend. The effective shellPath/shellArgs resolve per dialect (pwsh through the shared dsh-pwsh-local resolver, bash defaults unchanged), the child environment drops bash-only PS1/PROMPT_COMMAND markers and adds NO_COLOR for pwsh, and pwsh startup bootstraps the prompt function that emits the shared OSC 133;D + BEL marker, waiting (across follow-up sends) until the controlled prompt is actually visible so the banner-to-prompt gap cannot settle startup early. Bash behavior is byte-identical; the real-pwsh suite exercises persistent state and secret scrubbing on Windows. --- packages/e2b/e2b/tests/composition.e2e.ts | 2 +- packages/pty/pty-local/package.json | 1 + packages/pty/pty-local/src/config.ts | 54 ++++++++-- packages/pty/pty-local/src/index.ts | 74 ++++++++++--- packages/pty/pty-local/tests/config.spec.ts | 33 +++++- packages/pty/pty-local/tests/index.spec.ts | 105 ++++++++++++++++++- packages/pty/pty-local/tests/local.spec.ts | 50 ++++++++- packages/pty/pty-local/tests/session.spec.ts | 2 +- packages/pty/pty-local/tsconfig.json | 3 + 9 files changed, 292 insertions(+), 32 deletions(-) diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 76f54ba7fd..33f7e4ca31 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -97,7 +97,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { whenIdle: () => Promise.resolve(), } const backend = new LocalPtyBackend(ctx, { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], rows: 24, cols: 80, scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384, pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000, diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index 3db9e83889..0eb1ee3657 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { diff --git a/packages/pty/pty-local/src/config.ts b/packages/pty/pty-local/src/config.ts index be9ae3eed3..5b7a7c0e9d 100644 --- a/packages/pty/pty-local/src/config.ts +++ b/packages/pty/pty-local/src/config.ts @@ -1,14 +1,20 @@ /** Validated configuration for the local PTY backend. */ import z from '@deepseek-ai/schemastery' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' /** Public plugin configuration. */ export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -37,14 +43,43 @@ export interface Config { disposeGraceMs?: number } -/** Configuration after Schemastery defaults. */ -export type ResolvedConfig = Required +/** Configuration after Schemastery defaults and dialect resolution. */ +export type ResolvedConfig = Omit, 'shellDialect' | 'shellPath' | 'shellArgs'> & { + shellDialect: ShellDialect + shellPath: string + shellArgs: string[] +} + +/** Bash dialect default executable. */ +export const DEFAULT_BASH_SHELL = '/bin/bash' +/** Bash dialect default arguments (interactive, profile-free). */ +export const DEFAULT_BASH_ARGS = ['--noprofile', '--norc', '-i'] +/** Pwsh dialect default arguments (interactive host, profile-free). */ +export const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile'] + +/** + * Resolve the effective per-dialect shell specification. Defaulting is this + * explicit step: an unset `shellPath`/`shellArgs` selects the dialect's + * defaults, while an explicit value always wins. + * @param config - Schemastery-resolved plugin configuration. + * @returns the fully resolved configuration. + */ +export function resolveConfig(config: Config): ResolvedConfig { + const shellDialect = config.shellDialect ?? 'bash' + return { + ...(config as Required), + shellDialect, + shellPath: config.shellPath ?? (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL), + shellArgs: config.shellArgs ?? (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS), + } +} /** Schemastery config exposed by the plugin. */ export const Config: z = z.object({ backendType: z.string().default('shell'), - shellPath: z.string().default('/bin/bash'), - shellArgs: z.array(z.string()).default(['--noprofile', '--norc', '-i']), + shellDialect: z.union(['bash', 'pwsh'] as const).default('bash'), + shellPath: z.string().required(false), + shellArgs: z.array(z.string()).required(false), rows: z.number().default(40), cols: z.number().default(160), scrollbackLines: z.number().default(10_000), @@ -59,12 +94,11 @@ export const Config: z = z.object({ }) /** - * Assert every numeric config field is a positive safe integer and bounds compose. + * Assert every effective numeric config field is a positive safe integer and bounds compose. * @param config - Schemastery-resolved plugin configuration. - * @returns Narrows the input to the fully resolved configuration. */ -export function validateConfig(config: Config): asserts config is ResolvedConfig { - const resolved = config as ResolvedConfig +export function validateConfig(config: Config): void { + const resolved = resolveConfig(config) if (resolved.backendType.length === 0) throw new Error('pty-local: backendType must be non-empty') if (resolved.shellPath.length === 0) throw new Error('pty-local: shellPath must be non-empty') for (const [name, value] of Object.entries(resolved)) { diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index ee48a5821d..bb1ec520a9 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -12,7 +12,7 @@ import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -import { type Config, type ResolvedConfig, validateConfig } from './config.ts' +import { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' import { CONTROLLED_PROMPT } from './sanitize.ts' @@ -52,22 +52,39 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { }, { global: true }) } -function childEnvironment(spec: PtyBackendSpawnSpec): Record { +function childEnvironment(spec: PtyBackendSpawnSpec, dialect: ShellDialect): Record { // The subprocess provider supplies its own scrubbed ambient base; these are // deliberate terminal-specific overrides layered after it. - return { + const common = { TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', - PS1: CONTROLLED_PROMPT, - PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"', - BASH_SILENCE_DEPRECATION_WARNING: '1', DSH_SHELL: '1', DSH_SESSION_ID: spec.owner.id, DSH_PTY_SESSION_ID: spec.sessionId, } + if (dialect === 'pwsh') { + // pwsh ignores PS1/PROMPT_COMMAND; its prompt is installed by the startup + // bootstrap instead, and NO_COLOR keeps the renderer quiet. + return { ...common, NO_COLOR: '1' } + } + return { + ...common, + PS1: CONTROLLED_PROMPT, + PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"', + BASH_SILENCE_DEPRECATION_WARNING: '1', + } } +/** + * The pwsh prompt function that emits the shared OSC `133;D;` + BEL marker + * before every prompt, mirroring bash's PROMPT_COMMAND. `[char]27`/`[char]7` + * build the control bytes at runtime because raw ESC characters in submitted + * input are unreliable under PSReadLine. + */ +export const PWSH_PROMPT_SETUP = + "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + CONTROLLED_PROMPT + "' }" + function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutionPolicy): string[] { const argv = [config.shellPath, ...config.shellArgs] if (policy.mode === 'danger-full-access') return argv @@ -82,9 +99,42 @@ function spawnArgv(ctx: Context, config: ResolvedConfig, policy: SandboxExecutio // TODO(pty-initialize-race-home): Fold this outer abort race into // LocalPtySession.initialize when the send-state consolidation lands; the // session already owns the send lifecycle the race protects. -async function initializeSession(session: LocalPtySession, signal?: AbortSignal): Promise { +async function startupSession( + session: LocalPtySession, + dialect: ShellDialect, + signal?: AbortSignal, +): Promise { + const start = async (): Promise => { + if (dialect === 'bash') { + await session.initialize(signal) + return + } + // pwsh cannot install its prompt from the environment: write the prompt + // function through the session and wait for the first marker prompt, + // which is also the readiness contract of the bash initialize path. The + // banner-to-prompt gap can outlast the silence bound, so the wait loops + // over follow-up sends until the controlled prompt is actually visible + // (in the viewport or the retained scrollback when it landed between + // sends), bounded by the send deadline. + let viewport = '' + for (;;) { + const first = viewport.length === 0 + const operation = session.startSend({ + text: first ? PWSH_PROMPT_SETUP : '', + submit: first, + ...signal !== undefined ? { signal } : {}, + }) + const result = await operation.done + if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup') + if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout') + viewport = result.viewport + const scrollback = session.read({ offset: 0, count: 20 }).text + if (viewport.includes(CONTROLLED_PROMPT) || scrollback.includes(CONTROLLED_PROMPT)) break + } + session.motd = viewport + } if (signal === undefined) { - await session.initialize(signal) + await start() return } const aborted = Promise.withResolvers() @@ -92,7 +142,7 @@ async function initializeSession(session: LocalPtySession, signal?: AbortSignal) signal.addEventListener('abort', onAbort, { once: true }) try { signal.throwIfAborted() - await Promise.race([session.initialize(signal), aborted.promise]) + await Promise.race([start(), aborted.promise]) } finally { signal.removeEventListener('abort', onAbort) } @@ -125,7 +175,7 @@ export class LocalPtyBackend implements PtyBackend { const terminal = await this.spawnTerminal({ argv, cwd: spec.cwd ?? policy.workspaceRoot, - env: childEnvironment(spec), + env: childEnvironment(spec, this.config.shellDialect), rows: this.config.rows, cols: this.config.cols, graceMs: this.config.disposeGraceMs, @@ -133,7 +183,7 @@ export class LocalPtyBackend implements PtyBackend { }) const session = this.createSession(terminal, this.config) try { - await initializeSession(session, spec.signal) + await startupSession(session, this.config.shellDialect, spec.signal) return session } catch (error) { try { @@ -149,5 +199,5 @@ export class LocalPtyBackend implements PtyBackend { /** Register the local PTY backend. */ export function apply(ctx: Context, config: Config): void { validateConfig(config) - ctx.pty.registerBackend(new LocalPtyBackend(ctx, config)) + ctx.pty.registerBackend(new LocalPtyBackend(ctx, resolveConfig(config))) } diff --git a/packages/pty/pty-local/tests/config.spec.ts b/packages/pty/pty-local/tests/config.spec.ts index 87b479c22c..29ccf6b72b 100644 --- a/packages/pty/pty-local/tests/config.spec.ts +++ b/packages/pty/pty-local/tests/config.spec.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from 'vitest' import type { Config } from '@deepseek-ai/dsh-pty-local/src/config.ts' -import { validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts' +import { resolveConfig, validateConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts' function config(overrides: Partial = {}): Config { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 40, cols: 160, scrollbackLines: 100, scrollbackMaxBytes: 1024, maxReadBytes: 512, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 100, handoffGraceMs: 50, timeoutMs: 1000, disposeGraceMs: 100, @@ -30,3 +30,32 @@ describe('pty-local config', () => { expect(() => { validateConfig(config({ handoffGraceMs: 10, pollIntervalMs: 10 })) }).not.toThrow() }) }) + +describe('pty-local dialect resolution', () => { + it('defaults bash argv to the interactive profile-free form', () => { + const { shellPath, shellArgs, shellDialect } = resolveConfig({ backendType: 'shell', rows: 24, cols: 80 }) + expect(shellDialect).toBe('bash') + expect(shellPath).toBe('/bin/bash') + expect(shellArgs).toEqual(['--noprofile', '--norc', '-i']) + }) + + it('defaults pwsh argv to the interactive profile-free form and resolves the executable', () => { + const resolved = resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 }) + expect(resolved.shellDialect).toBe('pwsh') + expect(resolved.shellPath.length).toBeGreaterThan(0) + expect(resolved.shellArgs).toEqual(['-NoLogo', '-NoProfile']) + }) + + it('lets an explicit shell specification win over the dialect defaults', () => { + const resolved = resolveConfig({ + backendType: 'shell', shellDialect: 'pwsh', shellPath: '/custom/pwsh', shellArgs: ['-NoProfile'], rows: 24, cols: 80, + }) + expect(resolved.shellPath).toBe('/custom/pwsh') + expect(resolved.shellArgs).toEqual(['-NoProfile']) + }) + + it('validates the effective shell path, not only the raw one', () => { + expect(() => { validateConfig({ backendType: 'shell', shellDialect: 'bash', rows: 24, cols: 80 }) }).not.toThrow() + expect(() => { validateConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 }) }).not.toThrow() + }) +}) diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index fc07fc784c..318497670a 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { PassThrough } from 'node:stream' +import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -8,7 +9,8 @@ import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty' -import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local' +import type { PtySendRequest, PtyWaitReason } from '@deepseek-ai/dsh-pty' +import { LocalPtyBackend, PWSH_PROMPT_SETUP } from '@deepseek-ai/dsh-pty-local' import * as ptyLocal from '@deepseek-ai/dsh-pty-local' import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts' import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts' @@ -37,7 +39,7 @@ class RecordingSandbox extends SandboxProvider { function config(): ResolvedConfig { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, scrollbackLines: 10, scrollbackMaxBytes: 100, maxReadBytes: 50, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100, disposeGraceMs: 10, @@ -215,7 +217,7 @@ describe('LocalPtyBackend startup rollback', () => { expect(initialized).toHaveBeenCalledWith(undefined) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/workspace') }, }]) }) @@ -243,11 +245,11 @@ describe('LocalPtyBackend startup rollback', () => { expect(spawned).toMatchObject({ argv: ['/sandbox', '--', '/bin/bash', '-i'], - cwd: '/session-workspace', + cwd: resolve('/session-workspace'), }) expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{ argv: ['/bin/bash', '-i'], - policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: '/session-workspace' }, + policy: { mode: 'workspace-write', sessionId: 'agent', workspaceRoot: resolve('/session-workspace') }, }]) }) @@ -337,6 +339,99 @@ describe('LocalPtyBackend startup rollback', () => { expect(session.motd).toBe('dsh> ') await session.close('test complete') }) + + it('bootstraps a pwsh dialect through the prompt function and scrubs bash-only env', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + let spawned: SubprocessTerminalSpawnSpec | undefined + let sent: PtySendRequest | undefined + const session = { + motd: '', + startSend: (request: PtySendRequest) => { + sent = request + return { + done: Promise.resolve({ + viewport: 'setup-echo dsh> ', waitReason: 'stdin_read' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new LocalPtyBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async (spec) => { spawned = spec; return terminalHandle() }, + () => session, + ) + expect(await backend.spawn(spec(agent(ctx)))).toBe(session) + expect(sent).toMatchObject({ text: PWSH_PROMPT_SETUP, submit: true }) + expect(session.motd).toBe('setup-echo dsh> ') + expect(spawned?.env).toMatchObject({ + TERM: 'dumb', NO_COLOR: '1', DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1', + }) + expect(spawned?.env?.PS1).toBeUndefined() + expect(spawned?.env?.PROMPT_COMMAND).toBeUndefined() + }) + + it('keeps waiting for the marker prompt when the first send settles on silence', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sends: PtySendRequest[] = [] + const session = { + motd: '', + startSend: (request: PtySendRequest) => { + sends.push(request) + const second = sends.length > 1 + return { + done: Promise.resolve({ + viewport: second ? 'dsh> ' : 'PowerShell 7.6.4\n', + waitReason: 'inferred_idle' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new LocalPtyBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async () => terminalHandle(), + () => session, + ) + await backend.spawn(spec(agent(ctx))) + expect(sends).toHaveLength(2) + expect(sends[1]).toMatchObject({ text: '', submit: false }) + expect(session.motd).toBe('dsh> ') + }) + + it('rejects a pwsh bootstrap whose shell exits or times out', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sessionFor = (waitReason: PtyWaitReason): LocalPtySession => ({ + startSend: () => ({ + done: Promise.resolve({ + viewport: 'no-prompt', waitReason, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + }), + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + close: () => Promise.resolve(), + }) as unknown as LocalPtySession + const exited = new LocalPtyBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('session_exit')) + await expect(exited.spawn(spec(agent(ctx)))).rejects.toThrow('PTY shell exited during startup') + const timedOut = new LocalPtyBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout')) + await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') + }) }) describe('pty-local plugin shape', () => { diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index f23a6f077a..788669b8f9 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -1,4 +1,5 @@ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs' +import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -12,6 +13,7 @@ import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' import * as ptyLocal from '@deepseek-ai/dsh-pty-local' const roots: string[] = [] @@ -49,6 +51,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { async function harness( mode: 'danger-full-access' | 'workspace-write', timing: { idleSilenceMs?: number; handoffGraceMs?: number; timeoutMs?: number } = {}, + dialect: 'bash' | 'pwsh' = 'bash', ) { const root = mkdtempSync(join(tmpdir(), 'dsh-pty-local-')) roots.push(root) @@ -60,6 +63,7 @@ async function harness( await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root }) await ctx.plugin(LocalSubprocessService) const fiber = await ctx.plugin(ptyLocal, { + shellDialect: dialect, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: timing.idleSilenceMs ?? 250, @@ -114,7 +118,9 @@ function processIsRunning(pid: number): boolean { } } -describe('pty-local real shell', () => { +// The real-shell suite drives a POSIX bash over the actual node-pty terminal; +// Windows has no bash, and its pwsh counterpart lives in the describe below. +describe.skipIf(process.platform === 'win32')('pty-local real shell', () => { it('persists cwd and environment across sends, scrubs secrets, and closes', async () => { const previous = process.env.DSH_TEST_SECRET process.env.DSH_TEST_SECRET = 'must-not-leak' @@ -247,3 +253,45 @@ describe('pty-local real shell', () => { await ctx.pty.kill(agent, created.sessionId) }, 35_000) }) + +const hasPwsh = spawnSync( + resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], + { encoding: 'utf8' }, +).status === 0 + +describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { + it('bootstraps a persistent pwsh, persists state, and scrubs secrets', async () => { + const previous = process.env.DSH_TEST_SECRET + process.env.DSH_TEST_SECRET = 'must-not-leak' + try { + const { ctx, root, agent } = await harness('danger-full-access', { + idleSilenceMs: 300, + handoffGraceMs: 300, + timeoutMs: 8_000, + }, 'pwsh') + const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + expect(created.motd).toContain('dsh> ') + + const first = ctx.pty.startSend(agent, created.sessionId, { + text: '$env:KEEP = "ok"; Set-Location /', + submit: true, + }) + expect((await first.done).waitReason).toBe('stdin_read') + const second = ctx.pty.startSend(agent, created.sessionId, { + text: 'Write-Output "keep=$env:KEEP secret=$env:DSH_TEST_SECRET"', + submit: true, + }) + const result = await second.done + expect(result.viewport).toContain('keep=ok') + expect(result.viewport).toContain('secret=') + expect(result.viewport).not.toContain('must-not-leak') + + expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok') + expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true) + expect(ctx.pty.list(agent)).toEqual([]) + } finally { + if (previous === undefined) delete process.env.DSH_TEST_SECRET + else process.env.DSH_TEST_SECRET = previous + } + }, 30_000) +}) diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 0ca1835e53..e34decd517 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -130,7 +130,7 @@ function makeSession( function config(overrides: Partial = {}): ResolvedConfig { return { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80, scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64, pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100, disposeGraceMs: 20, diff --git a/packages/pty/pty-local/tsconfig.json b/packages/pty/pty-local/tsconfig.json index 1580e4c8a2..1a49f5ee67 100644 --- a/packages/pty/pty-local/tsconfig.json +++ b/packages/pty/pty-local/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../bash/pwsh-local" + }, { "path": "../../core/agent" }, From 0441312768ae04fdaad4c408aaa6be915e814418 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:15:27 +0800 Subject: [PATCH 009/137] feat(pty): persistent pwsh tool and the minimal-preset Windows stack Adds @deepseek-ai/dsh-tool-pwsh-persistent, the mirror of tool-bash-persistent for PowerShell: one owner-scoped persistent pwsh per agent, an Invoke-Expression wrapper with backtick-escaped bodies and exact native exit codes ( reset, \True fallback, catch to 1), PSReadLine-echo tolerance (the echoed wrapper is stripped from captured output and can never fabricate completion), and the same timeout/cancel/exit reset semantics with pwsh-flavored diagnostics. The minimal preset now gates its persistent shell stack by platform with the #2234 disabled interpolation: the bash rows mount on POSIX and the pwsh rows (pty-local shellDialect pwsh + the new tool) on win32, keeping exactly one persistent shell per host. windows-shell.spec pins the per-platform roster; the real Loader composition proves cwd/env persistence, multiline and here-string commands, large-output clipping, and exit/reset over a real ConPTY pwsh. --- .../agent-presets/minimal/agent.cordis.yml | 25 + apps/cli/package.json | 1 + apps/cli/tests/windows-shell.spec.ts | 23 +- .../pty/tool-pwsh-persistent/package.json | 62 ++ .../pty/tool-pwsh-persistent/src/index.ts | 476 ++++++++++++++ .../pty/tool-pwsh-persistent/src/invariant.ts | 31 + .../tests/loader-composition.spec.ts | 167 +++++ .../tool-pwsh-persistent/tests/tools.spec.ts | 594 ++++++++++++++++++ .../pty/tool-pwsh-persistent/tsconfig.json | 17 + pnpm-lock.yaml | 58 ++ tsconfig.host.json | 1 + 11 files changed, 1454 insertions(+), 1 deletion(-) create mode 100644 packages/pty/tool-pwsh-persistent/package.json create mode 100644 packages/pty/tool-pwsh-persistent/src/index.ts create mode 100644 packages/pty/tool-pwsh-persistent/src/invariant.ts create mode 100644 packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts create mode 100644 packages/pty/tool-pwsh-persistent/tests/tools.spec.ts create mode 100644 packages/pty/tool-pwsh-persistent/tsconfig.json diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 1ec0a6ea75..cce25d41b8 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -13,6 +13,8 @@ # The PTY registry is an agent-owned service, so it lives in an entry-local # realm. The backend still consumes the host sandbox policy and subprocess # implementation, while the tool registers into this agent's scoped catalog. +# Exactly one shell stack mounts per host: the bash stack gates off win32 and +# its pwsh twin gates off POSIX, mirroring the one-shot shell rows. - id: persistent-shell name: cordis:group group: true @@ -24,11 +26,13 @@ - id: pty-local name: '@deepseek-ai/dsh-pty-local' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 - id: persistent-bash name: '@deepseek-ai/dsh-tool-bash-persistent' + disabled: !!js process.platform === 'win32' config: timeoutMs: 300000 description: |- @@ -41,6 +45,27 @@ * Please avoid commands that may produce a very large amount of output. * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + - id: pty-pwsh + name: '@deepseek-ai/dsh-pty-local' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + timeoutMs: 300000 + + - id: persistent-pwsh + name: '@deepseek-ai/dsh-tool-pwsh-persistent' + disabled: !!js process.platform !== 'win32' + config: + timeoutMs: 300000 + description: |- + Run commands in a PowerShell shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * State is persistent across command calls and discussions with the user. + * Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. + # The editor requires absolute paths unconditionally. - id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/apps/cli/package.json b/apps/cli/package.json index 694732fe72..4f7758c722 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index 5898314e65..b4202c0dc3 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -122,7 +122,7 @@ describe('shipped agent presets gate both shell tools by platform', () => { } }) - it('minimal mounts no shell tool row at all (its shell is the PTY stack)', () => { + it('minimal mounts no shell tool row and gates its persistent shell stack by platform', () => { const entries: unknown = yaml.load( readFileSync(join(presetRoot, 'minimal', 'agent.cordis.yml'), 'utf8'), { schema: entryListSchema }, @@ -133,5 +133,26 @@ describe('shipped agent presets gate both shell tools by platform', () => { typeof entry === 'object' && entry !== null && (entry as Record).id === id )), `${id} must be absent from minimal`).toBe(false) } + const group = entries.find((entry): entry is Record => ( + typeof entry === 'object' && entry !== null && (entry as Record).id === 'persistent-shell' + )) + if (group === undefined) throw new TypeError('minimal preset must mount persistent-shell') + const rows = group.config as unknown[] + if (!Array.isArray(rows)) throw new TypeError('persistent-shell must carry a row list') + const byId = new Map(rows + .filter((entry): entry is Record => typeof entry === 'object' && entry !== null) + .map(entry => [entry.id, entry])) + // The bash stack (pty-local + persistent-bash) mounts on POSIX only; the + // pwsh twin (pty-local with shellDialect pwsh + persistent-pwsh) mounts on + // win32 only — exactly one persistent shell per host. + for (const id of ['pty-local', 'persistent-bash']) { + expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(true) + expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(false) + } + for (const id of ['pty-pwsh', 'persistent-pwsh']) { + expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(false) + expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(true) + } + expect(byId.get('pty-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' }) }) }) diff --git a/packages/pty/tool-pwsh-persistent/package.json b/packages/pty/tool-pwsh-persistent/package.json new file mode 100644 index 0000000000..1cf2e224d0 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/package.json @@ -0,0 +1,62 @@ +{ + "name": "@deepseek-ai/dsh-tool-pwsh-persistent", + "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/pty/tool-pwsh-persistent" + }, + "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" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/pty/tool-pwsh-persistent/src/index.ts b/packages/pty/tool-pwsh-persistent/src/index.ts new file mode 100644 index 0000000000..53c495585d --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/src/index.ts @@ -0,0 +1,476 @@ +/** + * Model-facing persistent `pwsh` tool over the owner-scoped PTY seam. + * @module @deepseek-ai/dsh-tool-pwsh-persistent + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty' +import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' +import { defineTool } from '@deepseek-ai/dsh-tools' + +// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file. +const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with Select-String in order to find the line numbers of what you are looking for.' +const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' +const SHELL_RESET_MESSAGE = 'The persistent pwsh shell was reset; the next pwsh call starts from the workspace with a fresh current directory and environment.' +const SHELL_PROMPT = '__DSH_PERSISTENT_PWSH_PROMPT__ ' +const TIMEOUT_CODE = 'PERSISTENT_PWSH_TIMEOUT' +// One page is enough to find a just-emitted completion marker; the full +// scrollback is assembled only when a command settles or needs partial output. +const SCROLLBACK_PAGE_LINES = 1_000 +const POLL_INTERVAL_MS = 25 + +const DEFAULT_DESCRIPTION = 'Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.' + +interface ResolvedConfig { + backendType: string + timeoutMs: number + maxOutputChars: number + description: string +} + +interface CommandMarkers { + start: string + end: string +} + +interface RetainedOutput { + text: string + truncated: boolean +} + +interface CapturedOutput { + text: string + incomplete: boolean + exitCode?: number +} + +interface PersistentShells { + get(owner: Agent, signal: AbortSignal): Promise + reset(owner: Agent, reason: string): Promise +} + +function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string { + if (content.length <= maxOutputChars && !incomplete) return content + return content.length <= maxOutputChars + ? content + TRUNCATED_MESSAGE + : content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE +} + +function markers(): CommandMarkers { + const nonce = randomUUID() + return { + start: `__DSH_PERSISTENT_PWSH_START_${nonce}__`, + end: `__DSH_PERSISTENT_PWSH_END_${nonce}:`, + } +} + +/** + * Escape a command body for embedding in the wrapper's double-quoted string. + * Backtick escapes keep every character literal: backtick first so the + * escapes this function inserts are never re-escaped, `$` so no expansion + * happens at wrapper construction, and `\r\n`/ESC so multi-line commands and + * raw control bytes ride one physical input line without PSReadLine mangling. + * @param value - the model's PowerShell command text. + * @returns the escaped double-quoted-string body. + */ +function quoteForPwsh(value: string): string { + return value + .replaceAll('`', '``') + .replaceAll('"', '`"') + .replaceAll('$', '`$') + .replaceAll('\r', '') + .replaceAll('\n', '`n') + .replaceAll('\x1b', '`e') +} + +function wrapCommand(command: string, marker: CommandMarkers): string { + // Keep the wrapper on one physical line: PSReadLine renders the echoed + // input, and a wrapped line would split the echo the extraction strips. + // The echoed END nonce can never fabricate completion because the status + // regex needs digits immediately after it and the echo continues with + // quote characters. + const body = quoteForPwsh(command) + return `Write-Output '${marker.start}'; $LASTEXITCODE = $null; $__s = 1; try { Invoke-Expression "${body}"; $__ok = $? } catch { $__ok = $false }; if ($null -ne $LASTEXITCODE) { $__s = [int]$LASTEXITCODE } else { $__s = if ($__ok) { 0 } else { 1 } }; Write-Output ('${marker.end}' + $__s)` +} + +function stripPrompt(text: string): string { + let result = text.replace(/\r?\n$/, '') + while (result.endsWith(SHELL_PROMPT)) { + result = result.slice(0, -SHELL_PROMPT.length) + } + return result.endsWith('\n') ? result.slice(0, -1) : result +} + +function commandOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + wrapper: string, +): CapturedOutput | undefined { + const text = snapshot.text + const end = text.lastIndexOf(marker.end) + const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1] + if (status === undefined) return undefined + const startMarker = text.lastIndexOf(marker.start, end) + const start = startMarker < 0 ? 0 : startMarker + marker.start.length + let captured = text.slice(start, end) + // The PSReadLine echo carries the wrapper source (including both marker + // nonces) before the real markers; anchor on the real markers excludes it, + // and stripping the wrapper covers the rare case where the real START + // scrolled out and extraction fell back to the echoed copy. + captured = captured.replaceAll(wrapper, '') + return { + text: stripPrompt(captured.replace(/^\r?\n/, '')), + incomplete: startMarker < 0, + exitCode: Number(status), + } +} + +function promptCompleted(result: PtySendResult): boolean { + return result.viewport.endsWith(SHELL_PROMPT) + || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) + || result.viewport.endsWith(`${SHELL_PROMPT}\n`) +} + +function partialOutput( + snapshot: RetainedOutput, + marker: CommandMarkers, + wrapper: string, + fallback: string, + fallbackTruncated = false, +): CapturedOutput { + const startMarker = snapshot.text.lastIndexOf(marker.start) + if (startMarker >= 0) { + return { + text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')), + incomplete: false, + } + } + const fallbackStart = fallback.lastIndexOf(marker.start) + const afterStart = fallbackStart < 0 + ? fallback + : fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '') + const fallbackEnd = afterStart.lastIndexOf(marker.end) + const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd) + return { + text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '').replaceAll(wrapper, '')), + incomplete: fallbackTruncated || fallbackStart < 0, + } +} + +async function pause(): Promise { + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) +} + +function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { + if (page.text.length === 0 || page.lineEnd <= offset) return undefined + return page.lineEnd +} + +function retainedScrollback( + ctx: Context, + owner: Agent, + id: PtySessionId, + latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), +): RetainedOutput { + const pages: string[] = latest.text.length === 0 ? [] : [latest.text] + let offset = latest.lineEnd + let truncated = latest.truncated + while (true) { + if (offset >= latest.totalLines) break + const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) + truncated ||= page.truncated + if (page.text.length > 0) pages.unshift(page.text) + const next = nextScrollbackOffset(page, offset) + if (next === undefined || next >= page.totalLines) break + offset = next + } + return { text: pages.join('\n'), truncated } +} + +function renderCaptured(output: CapturedOutput, maxOutputChars: number): string { + const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete) + const withPrefix = output.incomplete && output.text.length > 0 + ? LOST_PREFIX_MESSAGE + rendered + : rendered + const marker = output.exitCode !== undefined && output.exitCode !== 0 + ? `[exit code: ${output.exitCode}]` + : undefined + return appendStatusMarker(withPrefix, marker) +} + +function appendStatusMarker(content: string, marker: string | undefined): string { + if (marker === undefined) return content + return content.length === 0 ? marker : `${content}\n${marker}` +} + +function renderShellExitStatus( + content: string, + exitCode: number | null, + signal: NodeJS.Signals | null, +): string { + const marker = signal !== null + ? `[shell killed by signal: ${signal}]` + : exitCode !== null + ? `[shell exited: code ${exitCode}]` + : '[shell exited]' + return appendStatusMarker(content, marker) +} + +/** + * The pwsh prompt function that overrides the backend bootstrap value with + * this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime + * because raw ESC characters in submitted input are unreliable under + * PSReadLine. + */ +const PWSH_PROMPT_SETUP = + "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }" + +function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { + const pending = new WeakMap>() + const live = new Map() + const creating = new Set>() + const ownerCleanupInstalled = new WeakSet() + const lifecycle = new AbortController() + + const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { + if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return + await ctx.pty.kill(owner, id, reason) + } + + ctx.effect(() => async () => { + lifecycle.abort(new Error('tool-pwsh-persistent disposed during shell creation')) + await Promise.allSettled([...creating]) + const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-pwsh-persistent disposed') }) + await Promise.all(closing) + live.clear() + }, 'tool-pwsh-persistent shell cleanup') + + const reset = async (owner: Agent, reason: string): Promise => { + pending.delete(owner) + const id = live.get(owner) + live.delete(owner) + if (id !== undefined) await close(owner, id, reason) + } + + const get = (owner: Agent, signal: AbortSignal): Promise => { + const existing = pending.get(owner) + if (existing !== undefined) return existing + const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) + const creation = (async () => { + try { + const cwd = owner.session.header.cwd + const spawned = await ctx.pty.spawn(owner, { + type: config.backendType, + ...cwd === undefined ? {} : { cwd }, + }, combinedSignal) + live.set(owner, spawned.sessionId) + if (!ownerCleanupInstalled.has(owner)) { + ownerCleanupInstalled.add(owner) + owner.ctx.effect(() => () => { + pending.delete(owner) + live.delete(owner) + }, 'tool-pwsh-persistent owner cache cleanup') + } + const setup = ctx.pty.startSend(owner, spawned.sessionId, { + text: PWSH_PROMPT_SETUP, + submit: true, + signal: combinedSignal, + }) + const result = await setup.done + if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') { + throw new Error('persistent pwsh shell did not accept initialization') + } + return spawned.sessionId + } catch (error: unknown) { + await reset(owner, 'persistent pwsh initialization failed') + throw error + } + })() + const tracked = creation.finally(() => { + creating.delete(tracked) + }) + creating.add(tracked) + pending.set(owner, tracked) + return tracked + } + + return { get, reset } +} + +async function executeCommand( + ctx: Context, + shells: PersistentShells, + owner: Agent, + command: string, + config: ResolvedConfig, + upstream: AbortSignal, +): Promise { + using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE) + const id = await shells.get(owner, commandDeadline.signal) + const marker = markers() + const wrapped = wrapCommand(command, marker) + let first = true + let fallback = '' + let fallbackTruncated = false + + while (true) { + let operation + let result + try { + operation = ctx.pty.startSend(owner, id, { + text: first ? wrapped : '', + submit: first, + signal: commandDeadline.signal, + }) + first = false + result = await operation.done + } catch (error: unknown) { + await shells.reset(owner, 'persistent pwsh send failed') + throw error + } + const incremental = operation.readOutput() + fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport + fallbackTruncated ||= incremental.truncated || result.truncated + const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) + const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) + if (timedOut !== undefined) { + const snapshot = retainedScrollback(ctx, owner, id, latest) + const partial = renderCaptured( + partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), + config.maxOutputChars, + ) + await shells.reset(owner, 'persistent pwsh command timed out') + return [ + // TODO: Report a timeout only; this signal does not establish an OOM. + `Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`, + partial, + SHELL_RESET_MESSAGE, + ].join('\n') + } + if (commandDeadline.signal.aborted) { + await shells.reset(owner, 'persistent pwsh command aborted') + commandDeadline.signal.throwIfAborted() + } + if (latest.text.includes(marker.end)) { + const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker, wrapped) + if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) + } + if (result.sessionStatus.kind === 'exited') { + const snapshot = retainedScrollback(ctx, owner, id, latest) + await shells.reset(owner, 'persistent pwsh shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars), + result.sessionStatus.exitCode, + result.sessionStatus.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') + } + if (promptCompleted(result)) { + const snapshot = retainedScrollback(ctx, owner, id, latest) + return renderCaptured( + partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), + config.maxOutputChars, + ) + } + await pause() + } +} + +/** + * Register the model-facing persistent `pwsh` tool. + * @param ctx - plugin context carrying tools and the owner-scoped PTY service. + * @param config - selected PTY backend and command deadline. + */ +function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void { + const shells = persistentShells(ctx, config) + const queues = new WeakMap>() + + const serialized = async (owner: Agent, operation: () => Promise): Promise => { + const prior = queues.get(owner) ?? Promise.resolve() + const run = prior.then(operation, operation) + const tail = run.then(() => undefined, () => undefined) + queues.set(owner, tail) + try { + return await run + } finally { + if (queues.get(owner) === tail) queues.delete(owner) + } + } + + ctx.tools.register(defineTool({ + name: 'pwsh', + description: config.description, + parameters: { + command: { + type: 'string', + required: true, + description: 'The PowerShell command to run. Relative path is preferred in the command.', + }, + }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + async execute(args, exec) { + if (args.command.trim().length === 0) throw new Error('command must be a non-empty string') + const owner = exec.agent + if (owner === undefined) throw new Error('pwsh requires an owning agent session') + return serialized(owner, async () => { + exec.signal.throwIfAborted() + return executeCommand(ctx, shells, owner, args.command, config, exec.signal) + }) + }, + presentCall: args => ({ card: 'terminal', title: args.command }), + })) +} + +export const name = 'tool-pwsh-persistent' +export const inject = ['tools', 'pty'] + +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} + +/** Runtime configuration schema for the persistent pwsh tool. */ +export const Config: z = z.object({ + backendType: z.string().default('shell'), + timeoutMs: z.number().default(300_000), + maxOutputChars: z.number().default(16_000), + description: z.string().default(DEFAULT_DESCRIPTION), +}) + +/** Register one owner-scoped persistent `pwsh` tool. */ +export function apply(ctx: Context, config: Config): void { + const resolved: ResolvedConfig = { + backendType: config.backendType ?? 'shell', + timeoutMs: config.timeoutMs ?? 300_000, + maxOutputChars: config.maxOutputChars ?? 16_000, + description: config.description ?? DEFAULT_DESCRIPTION, + } + if (resolved.backendType.trim().length === 0) { + throw new Error('tool-pwsh-persistent: backendType must be non-empty') + } + if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) { + throw new Error('tool-pwsh-persistent: timeoutMs must be a positive safe integer') + } + if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) { + throw new Error('tool-pwsh-persistent: maxOutputChars must be a positive safe integer') + } + if (resolved.description.trim().length === 0) { + throw new Error('tool-pwsh-persistent: description must be non-empty') + } + registerPersistentPwsh(ctx, resolved) +} diff --git a/packages/pty/tool-pwsh-persistent/src/invariant.ts b/packages/pty/tool-pwsh-persistent/src/invariant.ts new file mode 100644 index 0000000000..6f436019f2 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh-persistent`. + * @module @deepseek-ai/dsh-tool-pwsh-persistent/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-tool-pwsh-persistent' + +/** Cordis companion plugin name. */ +export const name = 'tool-pwsh-persistent-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the adapter's private owner-to-shell cache has no + * observable event or data relation. Lifecycle tests prove its cleanup without + * adding a public API solely for an invariant. + */ +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/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..fc10f9cfe3 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -0,0 +1,167 @@ +import { spawnSync } from 'node:child_process' +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 Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import * as PtyLocal from '@deepseek-ai/dsh-pty-local' +import SandboxProvider from '@deepseek-ai/dsh-sandbox' +import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' +import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' + +const hasPwsh = spawnSync( + resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], + { encoding: 'utf8' }, +).status === 0 + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +class PassthroughSandbox extends SandboxProvider { + confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv { + return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] } + } +} + +function agent(ctx: Context, cwd: string): Agent { + const id = SessionId('persistent-pwsh-loader-agent') + const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd }) + const value: Agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader composition', () => { + it('preserves cwd and environment across calls', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-persistent-pwsh-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-agent'", + "- name: '@deepseek-ai/dsh-system-prompt'", + "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-pty'", + "- name: '@deepseek-ai/dsh-test-sandbox'", + "- name: '@deepseek-ai/dsh-sandbox-policy'", + ' config:', + ' mode: danger-full-access', + ` workspaceRoot: ${JSON.stringify(root)}`, + "- name: '@deepseek-ai/dsh-subprocess-local'", + "- name: '@deepseek-ai/dsh-pty-local'", + ' config:', + ' shellDialect: pwsh', + ' pollIntervalMs: 10', + ' exactProbeAfterMs: 20', + ' idleSilenceMs: 300', + ' handoffGraceMs: 300', + ' scrollbackLines: 20000', + ' timeoutMs: 8000', + ' disposeGraceMs: 500', + "- name: '@deepseek-ai/dsh-tool-pwsh-persistent'", + ' config:', + ' timeoutMs: 20000', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-agent', AgentRegistry], + ['@deepseek-ai/dsh-system-prompt', SystemPrompt], + ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-pty', PtyService], + ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], + ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], + ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService], + ['@deepseek-ai/dsh-pty-local', PtyLocal], + ['@deepseek-ai/dsh-tool-pwsh-persistent', ToolPwshPersistent], + ]) + context.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 context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await context.loader.await() + + const owner = agent(context, root) + const signal = new AbortController().signal + const execute = (id: string, command: string) => context!.tools.execute({ + signal, + callId: CallId(id), + name: 'pwsh', + arguments: { command }, + agent: owner, + }) + + expect(context.tools.schemas().map(schema => schema.name)).toEqual(['pwsh']) + await execute('state', '$env:KEEP = "loader"; New-Item -ItemType Directory -Force -Path nested | Out-Null; Set-Location nested') + const observed = text(await execute('observe', 'Write-Output "cwd=$PWD keep=$env:KEEP"')) + expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) + expect(observed).not.toContain('DSH_PERSISTENT_PWSH') + + const multiline = text(await execute( + 'multiline', + '$value = "line one"\nWrite-Output "${value}:it\'s fine"', + )) + expect(multiline).toBe("line one:it's fine") + expect(multiline).not.toContain('DSH_PERSISTENT_PWSH') + + const hereString = text(await execute( + 'here-string', + "$h = @'\nalpha\nbeta\n'@\nWrite-Output $h", + )) + expect(hereString).toBe('alpha\nbeta') + + const large = text(await execute('large-output', '1..12050 | ForEach-Object { $_ }')) + expect(large.startsWith('1\n2\n3\n')).toBe(true) + expect(large).toContain('') + expect(large).not.toContain('beginning of this command output was dropped') + + const exited = text(await execute('exit', 'exit')) + expect(exited).toContain('next pwsh call starts from the workspace') + expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root) + }, 60_000) +}) diff --git a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts new file mode 100644 index 0000000000..b1a6ee8395 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts @@ -0,0 +1,594 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import PtyService from '@deepseek-ai/dsh-pty' +import type { + PtyBackend, + PtyBackendSession, + PtyReadRequest, + PtySendOperation, + PtySendRequest, + PtySessionStatus, + PtySignal, + PtyWaitReason, +} from '@deepseek-ai/dsh-pty' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' + +const contexts: Context[] = [] +let callNumber = 0 + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() +}) + +function agent(ctx: Context, cwd: string | undefined): Agent { + const id = SessionId(`persistent-pwsh-owner-${callNumber}`) + const scope = ctx.plugin(() => {}) + const session = Session.create(id, [], { + version: 0, + id, + createdAt: 0, + ...cwd === undefined ? {} : { cwd }, + }) + const value: Agent = { + id, + options: {}, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx: scope.ctx, + send: () => {}, + followup: () => {}, + steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }), + inject: () => {}, + cancel() {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + ctx.agents.register(value) + return value +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(block => block.type === 'text').map(block => block.text).join('') +} + +function call( + ctx: Context, + owner: Agent | undefined, + command: string, + signal = new AbortController().signal, +) { + return ctx.tools.execute({ + signal, + callId: CallId(`persistent-pwsh-${++callNumber}`), + name: 'pwsh', + arguments: { command }, + ...owner === undefined ? {} : { agent: owner }, + }) +} + +type StubMode = + | 'normal' + | 'prompt-only' + | 'prompt-crlf' + | 'empty-read' + | 'stalled-read' + | 'exit' + | 'signal-exit' + | 'unknown-exit' + | 'wait-for-abort' + | 'end-on-abort' + | 'idle-then-normal' + | 'large' + | 'nonzero' + | 'torn-status' + | 'finish-torn-status' + | 'end-only' + | 'init-exit' + | 'init-timeout' + | 'spawn-error' + | 'send-error' + | 'prompt-after-idle' + | 'incremental-fallback' + | 'empty-page-after-latest' + | 'paged-scrollback' + | 'with-echo' + +const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ +const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ + +class StubPtySession implements PtyBackendSession { + readonly motd = '__DSH_PERSISTENT_PWSH_PROMPT__ ' + readonly pid = 123 + statusValue: PtySessionStatus = { kind: 'running' } + scrollback = this.motd + closed: string[] = [] + mode: StubMode + sends = 0 + pendingText = '' + historyTruncated = false + + constructor(mode: StubMode) { + this.mode = mode + } + + startSend(request: PtySendRequest): PtySendOperation { + this.sends += 1 + if (request.text.startsWith('function prompt')) { + if (this.mode === 'init-exit') { + this.statusValue = { kind: 'exited', exitCode: 1, signal: null } + return this.operation(Promise.resolve(this.result('', 'session_exit'))) + } + if (this.mode === 'init-timeout') { + return this.operation(Promise.resolve(this.result('', 'timeout'))) + } + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) + } + if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { + const done = new Promise>((resolve) => { + request.signal?.addEventListener('abort', () => { + const start = START_PATTERN.exec(request.text)?.[0] + const end = END_PATTERN.exec(request.text)?.[0] + const output = this.mode === 'end-on-abort' + ? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}` + : 'partial output' + this.scrollback += output + resolve(this.result(output, 'stdin_read')) + }, { once: true }) + }) + return this.operation(done) + } + if (this.mode === 'idle-then-normal') { + this.mode = 'normal' + this.pendingText = request.text + return this.operation(Promise.resolve(this.result('', 'inferred_idle'))) + } + if (this.mode === 'prompt-after-idle') { + if (request.text.length > 0) { + const start = START_PATTERN.exec(request.text)?.[0] + const output = `${start ?? ''}\npartial syntax output\n` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + const output = `pwsh: syntax error\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') { + const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n' + const output = `pwsh: syntax error${newline}${this.motd}${newline}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const sent = request.text.length > 0 ? request.text : this.pendingText + this.pendingText = '' + const start = START_PATTERN.exec(sent)?.[0] + const end = END_PATTERN.exec(sent)?.[0] + if (this.mode === 'with-echo') { + // The PSReadLine echo renders the submitted wrapper before the real + // markers; the tool must strip it from the captured result. + const output = `${sent}\n${start ?? ''}\nhello from stub\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'incremental-fallback') { + const incremental = `${start ?? ''}\nincrement\n${this.motd}` + return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) + } + if (this.mode === 'torn-status') { + const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` + this.scrollback += output + this.mode = 'finish-torn-status' + return this.operation(Promise.resolve(this.result(output, 'inferred_idle'))) + } + if (this.mode === 'finish-torn-status') { + const output = `7\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + if (this.mode === 'end-only') { + const output = `recovered output\n${end ?? ''}0\n${this.motd}` + this.scrollback += output + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + const commandOutput = this.mode === 'large' + ? 'x'.repeat(100) + : this.mode === 'nonzero' ? '' : 'hello from stub' + const exitCode = this.mode === 'nonzero' ? 7 : 0 + const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` + this.scrollback += output + if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') { + const exitedOutput = `${start ?? ''}\nhello from stub\n` + this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput + this.statusValue = this.mode === 'signal-exit' + ? { kind: 'exited', exitCode: null, signal: 'SIGTERM' } + : this.mode === 'exit' + ? { kind: 'exited', exitCode: 9, signal: null } + : { kind: 'exited', exitCode: null, signal: null } + return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit'))) + } + return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) + } + + read(request: PtyReadRequest) { + if (this.mode === 'empty-read') { + return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'stalled-read') { + return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false } + } + if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) { + return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false } + } + const lines = this.scrollback.split('\n') + if (this.mode === 'paged-scrollback') { + const offset = request.offset ?? 0 + const end = lines.length - offset + const start = Math.max(0, end - 3) + const returnedLines = end - start + return { + text: lines.slice(start, end).join('\n'), + totalLines: lines.length, + lineBegin: offset, + lineEnd: offset + returnedLines, + truncated: this.historyTruncated, + } + } + return { + text: this.scrollback, + totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length, + lineBegin: 0, + lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length, + truncated: this.historyTruncated, + } + } + + signal(_signal: PtySignal) { + return Promise.resolve({ delivered: true as const, targetPgid: 123 }) + } + + status() { + return this.statusValue + } + + async close(reason: string) { + this.closed.push(reason) + this.statusValue = { kind: 'exited', exitCode: 0, signal: null } + } + + private result(viewport: string, waitReason: PtyWaitReason) { + return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } + } + + private operation(done: Promise>, delta = ''): PtySendOperation { + return { + done, + readOutput: () => ({ delta, truncated: false }), + cancel: () => false, + } + } +} + +function stubBackend(initialMode: StubMode = 'normal') { + const sessions: StubPtySession[] = [] + const backend: PtyBackend = { + type: 'stub', + async spawn() { + if (initialMode === 'spawn-error') throw new Error('stub spawn failed') + const session = new StubPtySession(initialMode) + sessions.push(session) + return session + }, + } + return { backend, sessions } +} + +async function setup( + config: ToolPwshPersistent.Config = { backendType: 'stub' }, + initialMode: StubMode = 'normal', +) { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const stub = stubBackend(initialMode) + ctx.pty.registerBackend(stub.backend) + const fiber = await ctx.plugin(ToolPwshPersistent, config) + return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } +} + +describe('tool-pwsh-persistent', () => { + it('registers a configurable schema and reuses one owner shell', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + description: 'deployment-specific persistent shell', + }) + const schema = ctx.tools.schemas()[0] + expect(ctx.tools.schemas().map(item => item.name)).toEqual(['pwsh']) + expect(schema?.description).toBe('deployment-specific persistent shell') + expect(schema?.parameters).toMatchObject({ + required: ['command'], + properties: { command: { type: 'string' } }, + }) + expect(ctx.tools.get('pwsh')?.presentCall?.({ command: 'pwd' })) + .toEqual({ card: 'terminal', title: 'pwd' }) + + expect(text(await call(ctx, owner, 'Write-Output one'))).toBe('hello from stub') + expect(text(await call(ctx, owner, 'Write-Output two'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(1) + expect(stub.sessions[0]?.sends).toBe(3) + + const ownerWithoutCwd = agent(ctx, undefined) + expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + + await fiber.dispose() + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.get('pwsh')).toBeUndefined() + }) + + it('strips the echoed wrapper from captured output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'with-echo' + const result = text(await call(ctx, owner, 'Write-Output hi')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('__DSH_PERSISTENT_PWSH_START_') + expect(result).not.toContain('__DSH_PERSISTENT_PWSH_END_') + expect(result).not.toContain('Invoke-Expression') + }) + + it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { + const { ctx, owner, stub, fiber } = await setup({ + backendType: 'stub', + maxOutputChars: 10, + }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'idle-then-normal' + expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from') + + session.mode = 'incremental-fallback' + session.scrollback = '' + expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment') + + session.mode = 'prompt-only' + const promptFallback = text(await call(ctx, owner, 'bad {')) + expect(promptFallback).toContain('pwsh: synt') + expect(promptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + + session.mode = 'prompt-crlf' + session.scrollback = '' + const crlfPromptFallback = text(await call(ctx, owner, 'bad {')) + expect(crlfPromptFallback).toContain('pwsh: synt') + expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + + session.mode = 'end-only' + session.scrollback = '' + const missingStart = text(await call(ctx, owner, 'recover marker')) + expect(missingStart).toContain('recovered') + expect(missingStart).toContain('beginning of this command output was dropped') + expect(missingStart).toContain('') + + session.mode = 'large' + expect(text(await call(ctx, owner, 'large'))).toContain('') + + session.mode = 'nonzero' + expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]') + + session.mode = 'exit' + const exited = text(await call(ctx, owner, 'exit')) + expect(exited).toContain('hello from') + expect(exited).toContain('[shell exited: code 9]') + expect(exited).not.toContain('[exit code: 9]') + expect(exited).toContain('next pwsh call starts from the workspace') + expect(session.closed).toContain('persistent pwsh shell exited') + + await call(ctx, owner, 'new shell') + expect(stub.sessions).toHaveLength(2) + const replacement = stub.sessions[1]! + replacement.mode = 'signal-exit' + expect(text(await call(ctx, owner, 'kill shell'))) + .toContain('[shell killed by signal: SIGTERM]') + + await call(ctx, owner, 'another shell') + expect(stub.sessions).toHaveLength(3) + const externallyClosed = ctx.pty.list(owner)[0]?.sessionId + expect(externallyClosed).toBeDefined() + await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') + await fiber.dispose() + expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) + }) + + it('waits for status digits after a torn completion marker', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'torn-status' + stub.sessions[0]!.scrollback = '' + + expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') + }) + + it('reports a shell exit when the backend has no code or signal', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'unknown-exit' + + expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]') + }) + + it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'end-only' + session.scrollback = '' + expect(text(await call(ctx, owner, 'missing start'))) + .toContain('beginning of this command output was dropped') + + session.mode = 'empty-read' + expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub') + + session.mode = 'stalled-read' + expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub') + + session.mode = 'empty-page-after-latest' + expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub') + }) + + it('assembles retained output across backward scrollback pages', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'paged-scrollback' + session.scrollback = 'older one\nolder two\nolder three\nolder four\n' + + expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub') + }) + + it('sanitizes a prompt fallback reached after multiple polling rounds', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'prompt-after-idle' + session.scrollback = '' + const result = text(await call(ctx, owner, 'bad {')) + expect(result).toContain('partial syntax output') + expect(result).toContain('pwsh: syntax error') + expect(result).not.toContain('DSH_PERSISTENT_PWSH_PROMPT') + expect(result).not.toContain('DSH_PERSISTENT_PWSH_START') + }) + + it('does not attribute old scrollback truncation to a complete current command', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.historyTruncated = true + const result = text(await call(ctx, owner, 'short command')) + expect(result).toBe('hello from stub') + expect(result).not.toContain('') + expect(result).not.toContain('beginning of this command output was dropped') + }) + + it('closes a timed-out shell and reports bounded partial output', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'wait-for-abort' + const result = await call(ctx, owner, 'hang') + expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error') + expect(text(result)).toContain('partial output') + expect(text(result)).toContain('next pwsh call starts from the workspace') + expect(stub.sessions[0]?.closed).toContain('persistent pwsh command timed out') + }) + + it.each(['wait-for-abort', 'end-on-abort'] as const)( + 'cancels %s work, resets the shell, and releases a queued call', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 }) + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = mode + const controller = new AbortController() + const cancelled = call(ctx, owner, 'hang', controller.signal) + const queued = call(ctx, owner, 'after cancellation') + setTimeout(() => { + controller.abort(new Error('caller stopped')) + }, 5) + + expect((await cancelled).isError).toBe(true) + expect(text(await queued)).toBe('hello from stub') + expect(stub.sessions[0]?.closed).toContain('persistent pwsh command aborted') + expect(stub.sessions).toHaveLength(2) + }, + ) + + it.each(['init-exit', 'init-timeout'] as const)( + 'fails initialization and closes the unusable shell for %s', + async (mode) => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode) + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent pwsh initialization failed') + }, + ) + + it('clears a failed spawn without trying to close an unpublished shell', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error') + expect((await call(ctx, owner, 'pwd')).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + }) + + it('resets a cached shell after startSend fails', async () => { + const { ctx, owner, stub } = await setup() + await call(ctx, owner, 'warm up') + stub.sessions[0]!.mode = 'send-error' + expect((await call(ctx, owner, 'fails')).isError).toBe(true) + expect(stub.sessions[0]?.closed).toContain('persistent pwsh send failed') + expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + + it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(PtyService) + const spawnStarted = Promise.withResolvers() + const spawnAborted = Promise.withResolvers() + ctx.pty.registerBackend({ + type: 'slow', + spawn: spec => new Promise((_resolve, reject) => { + spawnStarted.resolve(undefined) + spec.signal?.addEventListener('abort', () => { + spawnAborted.resolve(undefined) + const reason: unknown = spec.signal?.reason + reject(reason instanceof Error + ? reason + : new Error('slow PTY spawn aborted', { cause: reason })) + }, { once: true }) + }), + }) + const fiber = await ctx.plugin(ToolPwshPersistent, { backendType: 'slow' }) + const owner = agent(ctx, '/workspace') + const running = call(ctx, owner, 'pwd') + await spawnStarted.promise + await fiber.dispose() + await spawnAborted.promise + expect((await running).isError).toBe(true) + expect(ctx.pty.list(owner)).toEqual([]) + }) + + it('rejects invalid config and invalid calls', async () => { + const { ctx, owner, stub } = await setup() + expect((await call(ctx, undefined, 'pwd')).isError).toBe(true) + expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string') + + const controller = new AbortController() + controller.abort(new Error('caller stopped')) + expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true) + expect(stub.sessions).toHaveLength(0) + + expect(() => { + ToolPwshPersistent.apply(new Context(), { backendType: '' }) + }).toThrow('backendType must be non-empty') + expect(() => { + ToolPwshPersistent.apply(new Context(), { timeoutMs: 0 }) + }).toThrow('timeoutMs must be a positive safe integer') + expect(() => { + ToolPwshPersistent.apply(new Context(), { maxOutputChars: 0 }) + }).toThrow('maxOutputChars must be a positive safe integer') + expect(() => { + ToolPwshPersistent.apply(new Context(), { description: ' ' }) + }).toThrow('description must be non-empty') + }) +}) diff --git a/packages/pty/tool-pwsh-persistent/tsconfig.json b/packages/pty/tool-pwsh-persistent/tsconfig.json new file mode 100644 index 0000000000..57c13a61c2 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/agent" }, + { "path": "../../core/tools" }, + { "path": "../pty" }, + { "path": "../../support/invariants" }, + { "path": "../../util/timeout" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 254839878b..b99d0ba5bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,6 +246,9 @@ importers: '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../packages/bash/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../packages/pty/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph @@ -5626,6 +5629,61 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/pty/tool-pwsh-persistent: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + 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-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-pty': + specifier: workspace:^ + version: link:../pty + '@deepseek-ai/dsh-pty-local': + specifier: workspace:^ + version: link:../pty-local + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../bash/pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/sandbox/sandbox: devDependencies: '@deepseek-ai/cordis': diff --git a/tsconfig.host.json b/tsconfig.host.json index b5e43cb523..b2525c8ddf 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -182,6 +182,7 @@ { "path": "./packages/pty/pty" }, { "path": "./packages/pty/pty-local" }, { "path": "./packages/pty/tool-bash-persistent" }, + { "path": "./packages/pty/tool-pwsh-persistent" }, { "path": "./packages/pty/tool-pty" }, { "path": "./packages/code-runtime/code-runtime" }, { "path": "./packages/code-runtime/code-runtime-worker" }, From da4701d28b984bc16f9aabddaddb80af55c973eb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:28:29 +0800 Subject: [PATCH 010/137] docs(pty): persistent pwsh READMEs, dialect docs, and the implemented note Adds the tool-pwsh-persistent README trio, documents the pty-local shellDialect and the subprocess-local Windows inspector (console-wide signalling, pseudo foreground groups, taskkill teardown) in both languages, updates the tool-pwsh and persistent-pty notes in place, and moves the pwsh-persistent-pty design note to implemented with the shipped Decision and Consequences. --- .../2026-08-11-pwsh-persistent-pty.i18n.yaml | 6 ++ .../2026-08-11-pwsh-persistent-pty.md | 65 +++++++++++++++++++ .../2026-08-11-pwsh-persistent-pty.zh.md | 65 +++++++++++++++++++ ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +- .../2026-07-16-persistent-pty-sessions.md | 2 +- .../2026-07-16-persistent-pty-sessions.zh.md | 2 +- packages/bash/tool-pwsh/README.i18n.yaml | 4 +- packages/bash/tool-pwsh/README.md | 2 +- packages/bash/tool-pwsh/README.zh.md | 2 +- packages/pty/pty-local/README.i18n.yaml | 4 +- packages/pty/pty-local/README.md | 5 +- packages/pty/pty-local/README.zh.md | 5 +- .../pty/tool-pwsh-persistent/README.i18n.yaml | 6 ++ packages/pty/tool-pwsh-persistent/README.md | 55 ++++++++++++++++ .../pty/tool-pwsh-persistent/README.zh.md | 55 ++++++++++++++++ .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 4 +- .../subprocess/subprocess-local/README.zh.md | 4 +- 18 files changed, 276 insertions(+), 18 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md create mode 100644 .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md create mode 100644 packages/pty/tool-pwsh-persistent/README.i18n.yaml create mode 100644 packages/pty/tool-pwsh-persistent/README.md create mode 100644 packages/pty/tool-pwsh-persistent/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml new file mode 100644 index 0000000000..c74339813b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +2026-08-11-pwsh-persistent-pty.md: 51988586a6c260d528e140ab718eb302355c7314 +2026-08-11-pwsh-persistent-pty.zh.md: e753c050827b216330ac9bce0258f9b59d866999 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md new file mode 100644 index 0000000000..51988586a6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -0,0 +1,65 @@ +# Agent Note: Persistent pwsh over the PTY seam on Windows + +Status: implemented + +English | [中文](2026-08-11-pwsh-persistent-pty.zh.md) + +## Problem + +The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-pty-local` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. + +The gap excluded Windows workflows whose state lives in a terminal: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting its foreground command — the same class of work the persistent bash pty serves on POSIX. + +Two foundations already existed. The PTY service itself (`ctx.pty` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. + +## Decision + +A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `pty-local`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. + +### Windows substrate in `@deepseek-ai/dsh-subprocess-local` + +`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes creation-time identities (pid-reuse fencing like the POSIX start identity), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. + +`LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. + +### Shell dialect in `@deepseek-ai/dsh-pty-local` + +One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred). + +### `@deepseek-ai/dsh-tool-pwsh-persistent` + +A new package mirroring `tool-bash-persistent`: same `Config` (`backendType` default `shell`, `timeoutMs`, `maxOutputChars`, `description`), same owner-scoped shell registry and serialized per-owner queue, same timeout/abort/exit/reset paths. The tool name is `pwsh`; it never co-mounts with the one-shot `tool-pwsh` because the preset rows are mutually exclusive per platform. + +Commands run through a wrapper that resets `$LASTEXITCODE` (assignable, verified), invokes the body via `Invoke-Expression` in a backtick-escaped double-quoted string (`quoteForPwsh`: backtick, quote, `$`, CRLF, and ESC escapes, so no raw control characters ride the input line and the wrapper survives ConstrainedLanguage), and reports the exact native exit code, `1` for a terminating PowerShell error, or `0` for success. PSReadLine echoes the submitted wrapper back into the stream — there is no `stty -echo` equivalent — so the extraction strips the wrapper source from captured output; the echo can never fabricate completion because the status regex needs digits immediately after the END nonce and the echo continues with quote characters. The prompt function installs the tool's own prompt (`__DSH_PERSISTENT_PWSH_PROMPT__ `) over the backend bootstrap value, the same two-layer structure as bash. + +### Composition + +The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`pty-local` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`pty-local` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. + +### Testing + +The subprocess-local and pty-local suites now run on Windows: bash-shaped fixtures self-skip through platform gates, the spawn/terminal suites translate their simple shell commands to node one-liners and exercise injected POSIX group paths, and the koffi-backed inspector joins the windows-only coverage exclusions on Linux while the windows-native lane enforces its 100% coverage. The tool suite mirrors `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. + +## Alternatives considered + +- **A separate `pty-pwsh-local` backend package.** Rejected: the local session, sanitizer, readiness tiers, and sandbox fence are shared machinery; duplicating the 500-line session for argv/env/startup differences trades one config field for a package of copy-paste, unlike the bash group's thin parallel executors. +- **tasklist or wmic polling for the process tree.** Rejected: `inspectForeground` runs on every readiness poll (~50 ms), so a spawned probe per tick is untenable, and wmic is removed from current Windows releases. koffi + Toolhelp32 is in-process and cheap. +- **A native helper or `GenerateConsoleCtrlEvent` for SIGINT.** Rejected: writing `\x03` to ConPTY input interrupts running commands (verified) with zero new code. The semantic difference — at a prompt, `\x03` cancels the pending line instead of signalling a process — is documented rather than engineered around. +- **Base64 body encoding for the wrapper.** Rejected: decoding needs `[Convert]`/`[System.Text.Encoding]` calls whose ConstrainedLanguage status is unproven, while backtick-escaped double-quoted strings use only language-level constructs and were verified end-to-end. +- **Tolerating the echo without stripping the wrapper.** Rejected: in complete and prompt-settled paths the echo is naturally excluded, but timeout and lost-START fallbacks would leak the wrapper source (including marker nonces) into model-visible text. +- **Resurrecting a BEL model-notification channel.** Rejected: the current implementation consumes no marker payload and delivers no BEL events; the design aligns with the current implementation and keeps the deferred item deferred. +- **Windows PowerShell 5.1 as a first-class target.** Rejected: pwsh 7 (including the Store install) is the target; `resolvePwshPath` keeps 5.1 as the last-resort executable fallback without promising full persistent-shell behavior on it. + +## Consequences + +**Windows became a first-class persistent-shell host.** The pty family now runs, tests, and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. + +**The windows-native coverage flip is a standing commitment.** subprocess-local and pty-local sources are coverage-required on win32; their suites run there (with platform gates and node-translated commands) and must keep 100% coverage on the windows-native lane. + +**Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. + +**Windows teardown and signalling differ from POSIX.** taskkill without `/F` does not terminate console processes (the TERM tier is a grace wait before `/F`), SIGINT is console-wide Ctrl-C, SIGTSTP/SIGHUP are unavailable, and externally taskkilled shells may not fire node-pty's exit notification — the handle settles from verified absence instead. + +**Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks. + +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the prompt function's `[Console]::` call; the `Write-Host -NoNewline` fallback is designed and decided by the Windows-native lane. A model redefinition of the `prompt` function degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md new file mode 100644 index 0000000000..e753c05082 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -0,0 +1,65 @@ +# Agent Note: Windows 上基于 PTY seam 的持久化 pwsh + +Status: implemented + +[English](2026-08-11-pwsh-persistent-pty.md) | 中文 + +## 问题 + +harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-pty-local` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 + +这个缺口排除了状态驻留在终端里的 Windows 工作流:单步调试、在 Python 或 Node REPL 中探索、中断前台命令后回到原 shell —— 正是持久 bash pty 在 POSIX 上服务的同一类工作。 + +两个基础已经存在。PTY 服务本身(`ctx.pty` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 + +## 决定 + +模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`pty-local` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 + +### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 + +`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 创建时间身份(与 POSIX start-identity 相同的 PID 复用防护),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 + +`LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 + +### `@deepseek-ai/dsh-pty-local` 的 shell 方言 + +一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。 + +### `@deepseek-ai/dsh-tool-pwsh-persistent` + +新包镜像 `tool-bash-persistent`:同样的 `Config`(`backendType` 默认 `shell`、`timeoutMs`、`maxOutputChars`、`description`)、同样的 owner 作用域 shell 注册表与每 owner 串行队列、同样的超时/中止/退出/重置路径。工具名是 `pwsh`;它与一次性 `tool-pwsh` 永不共挂,因为预设行按平台互斥。 + +命令经包装器执行:先重置 `$LASTEXITCODE`(可赋值,已实测),通过 `Invoke-Expression` 在反引号转义的双引号字符串中执行 body(`quoteForPwsh`:反引号、引号、`$`、CRLF 与 ESC 转义,输入行上不携带裸控制字符,包装器可在 ConstrainedLanguage 下存活),报告精确原生退出码、PowerShell 终止性错误的 `1` 或成功的 `0`。PSReadLine 会把提交的包装器回显进流——没有 `stty -echo` 的对应物——因此提取会从捕获输出中剥离包装器原文;回显无法伪造完成,因为状态正则要求 END nonce 后紧跟数字,而回显继续是引号字符。prompt 函数安装工具自有提示符(`__DSH_PERSISTENT_PWSH_PROMPT__ `)覆盖 backend 引导值,与 bash 的双层结构相同。 + +### 组合 + +minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`pty-local` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `pty-local` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 + +### 测试 + +subprocess-local 与 pty-local 套件现在在 Windows 上运行:bash 形态 fixture 通过平台门控自跳过,spawn/terminal 套件把简单 shell 命令翻译为 node 单行并覆盖注入的 POSIX 组路径,koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免,windows-native 车道强制执行其 100% 覆盖。工具套件镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 + +## 备选方案 + +- **独立的 `pty-pwsh-local` backend 包。** 拒绝:本地 session、sanitizer、就绪档位和沙箱栅栏是共享机制;为一个 config 字段复制 500 行 session 换来的是一包复制粘贴,与 bash 组并置薄 executor 的情形不同。 +- **tasklist 或 wmic 轮询进程树。** 拒绝:`inspectForeground` 每次就绪轮询(约 50ms)都跑,每 tick 生成一次探测进程不可行;wmic 已从现行 Windows 移除。koffi + Toolhelp32 是进程内、廉价的。 +- **为 SIGINT 加原生 helper 或 `GenerateConsoleCtrlEvent`。** 拒绝:向 ConPTY 输入写 `\x03` 即可中断运行中的命令(已实测),零新增代码。语义差异——在提示符处 `\x03` 取消当前行而不是给进程发信号——文档化而不是绕开。 +- **包装器 body 用 base64 编码。** 拒绝:解码需要 `[Convert]`/`[System.Text.Encoding]` 调用,其在 ConstrainedLanguage 下的可用性未证实;反引号转义的双引号字符串只用语言级构造,且已端到端实测。 +- **容忍回显而不剥离包装器。** 拒绝:完整路径和提示符就绪路径下回显天然被排除,但超时和 START 丢失的回退会把包装器源码(含 marker nonce)泄漏进模型可见文本。 +- **复活 BEL 模型通知通道。** 拒绝:当前实现不消费任何 marker 载荷、不投递任何 BEL 事件;设计对齐当前实现,deferred 项保持 deferred。 +- **把 Windows PowerShell 5.1 当一等目标。** 拒绝:pwsh 7(含 Store 安装)是目标;`resolvePwshPath` 保留 5.1 作为最后的可执行回退,但不承诺持久 shell 在其上的完整行为。 + +## 后果 + +**Windows 成为一等公民的持久 shell 宿主。** pty 家族现在在 windows-native 车道上运行、测试并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 + +**windows-native 覆盖翻转是常驻承诺。** subprocess-local 与 pty-local 源码在 win32 上受覆盖约束;它们的套件在那里运行(带平台门控与 node 翻译命令),并必须在 windows-native 车道保持 100% 覆盖。 + +**Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 + +**Windows 的拆卸与信号不同于 POSIX。** 不带 `/F` 的 taskkill 无法终止控制台进程(TERM 档是 `/F` 升级前的宽限等待)、SIGINT 是控制台级 Ctrl-C、SIGTSTP/SIGHUP 不可用,且被外部 taskkill 的 shell 可能不触发 node-pty 的退出通知——句柄改从验证的消失状态结算。 + +**输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 + +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝 prompt 函数的 `[Console]::` 调用;`Write-Host -NoNewline` 回退已设计好,由 Windows-native 车道裁决。模型重定义 `prompt` 函数会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 32fa522443..eab1b8987e 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: fb9cd06bade7bc357baa738f0d9dd03b7f5b7936 -2026-07-16-persistent-pty-sessions.zh.md: 55a5848c1ab1e8c2cd3b29f2d4748ea4abbe088c +2026-07-16-persistent-pty-sessions.md: fa9d90e3ded97b279c5cad7c8b5733333403beaf +2026-07-16-persistent-pty-sessions.zh.md: f801f4d0728452b2a5acf75c5bcbef0d9e1e6901 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index fb9cd06bad..fa9d90e3de 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -134,7 +134,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited. - Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol. - Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work. -- Windows/ConPTY support requires a backend with Windows-native process ownership and signaling semantics. +- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../../architecture/2026-08-11-pwsh-persistent-pty.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 55a5848c1a..f801f4d072 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -134,7 +134,7 @@ plugins: - 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。 - harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。 - 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。 -- Windows/ConPTY 支持需要具备 Windows 原生进程所有权与信号语义的后端。 +- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../../architecture/2026-08-11-pwsh-persistent-pty.md)。 ## 备选方案 diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index 04e314ac14..edac189179 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/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/bash/tool-pwsh/README.md -README.md: 4126e718c569f17fb8be465351b2576970e93c63 -README.zh.md: aba669733a7ecb9924287abb298bb9a154b5afa7 +README.md: b843ed11823aadec7c28cc26e45da41701e40d3d +README.zh.md: 04f471fd15d6bf4343f674a29d03bb48c4870fdb diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 4126e718c5..b843ed1182 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -121,6 +121,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. -- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. +- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index aba669733a..04f471fd15 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -121,6 +121,6 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work - **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 -- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 +- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/pty/pty-local/README.i18n.yaml b/packages/pty/pty-local/README.i18n.yaml index 238e2aa749..f90a07050d 100644 --- a/packages/pty/pty-local/README.i18n.yaml +++ b/packages/pty/pty-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/pty/pty-local/README.md -README.md: 5acc92853e6e8fcb8938c48e391559bf4a28fb75 -README.zh.md: 7d070a9a4aceab921264f020716fbdf16fdd57ff +README.md: 4565f9a6efd122e6302368c2d616f09649f0cae0 +README.zh.md: 44c1440c20a428048e1509b22141066d16758d31 diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index 5acc92853e..4565f9a6ef 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -8,6 +8,8 @@ Persistent shell backend for `ctx.pty` over `ctx.subprocess.spawnTerminal`. It s The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. +`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. + Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `PtyBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation marks queued input as canceled before asking the terminal handle to signal the current foreground process group with a real `SIGINT`; if asynchronous pre-write inspection later settles, it cannot execute that input. If a provider write is already in flight, signalling waits for it to settle; a rejected write sends no signal. The canceled send retains its slot until the write and foreground signalling settle, so a successor cannot receive either late bytes or that signal. A provider write or signal that never settles therefore retains the slot indefinitely; closing the session (`terminal_close`) is the recovery. The absolute deadline remains armed while cancellation waits. A signal failure is a terminal transport failure and rejects the active send. Cancellation never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close rejects new public signals, stops readiness polling, and awaits the handle's provider-owned complete-session termination before settling the active send as `session_exit`. @@ -31,6 +33,7 @@ A standing-policy change appends an owner-rendered superseding runtime-context s ## Known Limitations and Deferred Work - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. -- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. +- Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. +- The pwsh `prompt` bootstrap writes through `[Console]::`, which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/pty/pty-local/README.zh.md b/packages/pty/pty-local/README.zh.md index 7d070a9a4a..44c1440c20 100644 --- a/packages/pty/pty-local/README.zh.md +++ b/packages/pty/pty-local/README.zh.md @@ -8,6 +8,8 @@ 该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 +`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 + 就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`PtyBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 取消发送时,系统会先把排队输入标记为已取消,再要求终端句柄向当前前台进程组发送真正的 `SIGINT`;异步写入前检查即使随后结算,也无法执行该输入。如果提供方写入已在途,信号发送会等待其结算;写入被拒绝时不会发送信号。已取消的 send 会保留其位置,直到写入与前台信号发送都结算,因此后继 send 不会收到延迟字节或该信号。因此,永不结算的提供方写入或信号会无限期保留该位置;恢复手段是关闭会话(`terminal_close`)。取消等待期间,绝对 deadline 仍保持启用。信号发送失败是终端传输失败,会拒绝活跃 send。取消绝不会通过写入 `\x03` 模拟中断,因此,即使程序运行在 raw 模式下,也仍可取消。关闭操作会拒绝新的公开信号、停止就绪轮询,并等待由句柄提供方负责的完整会话终止,然后才把活跃 send 结算为 `session_exit`。 @@ -31,6 +33,7 @@ ## 已知限制与暂缓事项 - 输出按行规范化;不支持全屏备用缓冲区交互。 -- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。 +- 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 +- pwsh `prompt` 引导通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 diff --git a/packages/pty/tool-pwsh-persistent/README.i18n.yaml b/packages/pty/tool-pwsh-persistent/README.i18n.yaml new file mode 100644 index 0000000000..3335ee04fc --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/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/pty/tool-pwsh-persistent/README.md +README.md: 32fcaf15d38648c347e6423c9b68753ccca91287 +README.zh.md: 8c2201657796d2d6bd39ea5d130ee246a58e17d9 diff --git a/packages/pty/tool-pwsh-persistent/README.md b/packages/pty/tool-pwsh-persistent/README.md new file mode 100644 index 0000000000..32fcaf15d3 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/README.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-pwsh-persistent + +English | [中文](README.zh.md) + +Model-facing `pwsh(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend (a `pty-local` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect. + +## Config + +| Key | Default | Meaning | +|---|---:|---| +| `backendType` | `shell` | Registered PTY backend used for each Agent shell. | +| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | +| `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. | +| `description` | Persistent-shell description | Model-facing environment contract. | + +## Model Experience + +### Tool schema + +#### What the model sees + +The generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance. + +#### Token effect + +Fixed schema cost while `pwsh` is visible. + +#### KV Cache effect + +Prefix-stable while the configured description and schema remain unchanged. + +### Tool results + +#### What the model sees + +Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the PTY has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. + +#### Token effect + +Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result. + +#### KV Cache effect + +Append-only tool results follow the reusable request prefix. + +## Known Limitations and Deferred Work + +- The tool requires an owning Agent and a real PTY backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh). +- **Input echo is unavoidable**: PowerShell's PSReadLine renders submitted input back into the terminal stream, and there is no `stty -echo` equivalent. The marker-anchored extraction excludes the echo in complete results; the wrapper-source strip covers fallback paths, but a wrapper that wraps across the terminal width may leave a partial echo in partial-output results, bounded by `maxOutputChars`. +- Raw ESC characters inside model commands are unsupported: PSReadLine consumes them before execution. The wrapper escapes the control bytes it needs (`[char]27`-built OSC markers, backtick escapes for the body). +- A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path. +- There is no interactive stdin during a command: a foreground command that reads input blocks until the readiness timeout, which resets the shell. +- SIGTSTP/SIGHUP are unavailable on Windows (backend-rejected); SIGINT is delivered as a console-wide Ctrl-C input write, which at a prompt cancels the pending line instead of signalling a process. +- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the prompt function's `[Console]::` call; the backend's documented `Write-Host -NoNewline` fallback is selected by the Windows-native lane evidence. +- The BEL-terminated OSC marker remains a readiness signal only; a BEL event channel to the model stays deferred, aligned with the current implementation. diff --git a/packages/pty/tool-pwsh-persistent/README.zh.md b/packages/pty/tool-pwsh-persistent/README.zh.md new file mode 100644 index 0000000000..8c22016577 --- /dev/null +++ b/packages/pty/tool-pwsh-persistent/README.zh.md @@ -0,0 +1,55 @@ +# @deepseek-ai/dsh-tool-pwsh-persistent + +[English](README.md) | 中文 + +模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.pty` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 PTY backend(配置 `shellDialect: pwsh` 的 `pty-local` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。 + +## 配置 + +| 键 | 默认值 | 含义 | +|---|---:|---| +| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY backend。 | +| `timeoutMs` | `300000` | 单条命令的墙钟上限;超时关闭 shell。 | +| `maxOutputChars` | `16000` | 保留的命令输出字符上限;固定诊断文本在其后追加。 | +| `description` | 持久 shell 描述 | 模型可见的环境契约。 | + +## 模型体验 + +### 工具 schema + +#### 模型看到什么 + +生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh-persistent),含配置的 `description`。本插件不贡献独立的 system-prompt 段落;persona 与环境指引由部署方负责。 + +#### Token 影响 + +`pwsh` 可见期间每个请求有固定的 schema 成本。 + +#### KV Cache 影响 + +配置的 description 与 schema 不变时前缀稳定。 + +### 工具结果 + +#### 模型看到什么 + +命令共享每个 Agent 的一个 shell,因此 cwd、`$env:` 变量、函数和后台任务跨调用保留。结果排除私有完成标记、shell 提示符与回显的输入行(PSReadLine 会把提交的输入渲染回输出流;marker 锚定提取与包装器原文剥离将其移除)。非零包装命令追加 `[exit code: N]` —— 命令运行原生程序时是精确的原生退出码,PowerShell 终止性错误为 `1`。shell 在报告状态前退出的,改为追加 `[shell exited: code N]`、`[shell killed by signal: SIG]` 或 `[shell exited]`(backend 两者都没有时;Windows 强杀按无 signal 的 exit 1 报告),然后重置并告知模型下一次调用从全新 shell 开始。长输出保留最早的前缀并附裁剪提示;若 PTY 已丢弃该前缀,结果会明确说明。超时返回有界的部分输出、关闭不确定的 shell 并报告重置。 + +#### Token 影响 + +数据相关。`maxOutputChars` 限制保留的命令输出;固定裁剪、前缀丢失、状态、超时与重置诊断可能扩展结果。 + +#### KV Cache 影响 + +追加式工具结果跟随可复用的请求前缀。 + +## 已知限制与延后工作 + +- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 PTY backend(Windows ConPTY 或 POSIX 上的 pwsh)。 +- **输入回显不可避免**:PowerShell 的 PSReadLine 会把提交的输入渲染回终端流,且没有 `stty -echo` 的对应物。完整结果中 marker 锚定提取排除回显;包装器原文剥离覆盖回退路径,但跨越终端宽度的包装器折行可能在部分输出结果中残留片段回显,受 `maxOutputChars` 约束。 +- 模型命令中的裸 ESC 字符不受支持:PSReadLine 会在执行前吞掉它们。包装器转义它需要的控制字节(`[char]27` 构造的 OSC 标记、body 的反引号转义)。 +- 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。 +- 命令执行期间没有交互 stdin:读取输入的前台命令会阻塞到就绪超时,随后重置 shell。 +- SIGTSTP/SIGHUP 在 Windows 不可用(backend 拒绝);SIGINT 以控制台级 Ctrl-C 输入写入投递,在提示符处取消当前行而非向进程发信号。 +- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝 prompt 函数的 `[Console]::` 调用;backend 文档化的 `Write-Host -NoNewline` 回退由 Windows-native 车道证据裁决。 +- BEL 终结的 OSC 标记仍只是就绪信号;面向模型的 BEL 事件通道保持延后,与当前实现对齐。 diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 68b8ff7d50..8b6e59abe5 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-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/subprocess/subprocess-local/README.md -README.md: 2817e02861db6caad89cad258d14a90c34afcbaf -README.zh.md: 251b994a35e8bd7c84827957a548a1f352583ac6 +README.md: 0685cb32e4c80589de7a1f284712655a5de05b37 +README.zh.md: 1442d7c62e67c5d006f4b0a43d9ea090dbf2f284 diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 2817e02861..0685cb32e4 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes start identities, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's absence through those identities because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles only so its own disposal can escalate every running tree and await its exit; settled and spawn-failed handles leave the live set on settlement. ## Model Experience @@ -25,7 +25,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **Windows tree support is best-effort** — termination routes through `taskkill /PID /T /F` with all outcomes contained (absent tree, races, missing binary), and liveness falls back to the direct-child boundary. -- **Terminal process inspection is Linux/macOS only** — the terminal primitive fails when its inspector has no supported platform implementation; Linux exact probes cover x64 and arm64, while macOS uses `ps` snapshots. +- **Windows terminal signalling is console-wide** — SIGINT is delivered as a `\x03` Ctrl-C input write that conhost turns into a console-wide CTRL_C event; SIGTSTP and SIGHUP are rejected as unavailable; a `taskkill` without `/F` does not terminate console processes, so the teardown TERM tier is a grace wait before the `/F` escalation. Windows readiness has no exact stdin-wait tier: the prompt-marker fast path compares the shell pid as the pseudo foreground group, and silence/timing tiers cover the rest. - **A daemonized terminal descendant can still escape the observable boundary** — on macOS, a child that reparents before any foreground-inspection snapshot is no longer discoverable from the `node-pty` root; on Linux, a child that calls `setsid` leaves both the tree and owned terminal session. The local provider does not add a continuous process-table monitor. - **The credential scrub is a name heuristic** — `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSPHRASE*`) pass through, and a whitelist for over-scrubbed vars is noted future work. - **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 251b994a35..1442d7c62e 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该能力入口被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 启动身份,把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组),拆卸则通过这些身份验证 shell 已消失——因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,只为让自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;已结算与 spawn 失败的句柄在结算时即离开存活集合。 ## 模型体验 @@ -25,7 +25,7 @@ ## 已知限制与暂缓事项 - **Windows 进程树支持仅为尽力而为**:终止经由 `taskkill /PID /T /F` 完成,所有结果都被就地吸收,不向外抛出(进程树已不存在、竞态、二进制缺失),存活探测则回退到直接子进程边界。 -- **终端进程检查仅支持 Linux/macOS**:检查器没有受支持的平台实现时,终端原语会失败;Linux 精确探针覆盖 x64 与 arm64,macOS 则使用 `ps` 快照。 +- **Windows 终端信号是控制台级的**:SIGINT 以 `\x03` Ctrl-C 输入写入投递,由 conhost 转为控制台级 CTRL_C 事件;SIGTSTP 与 SIGHUP 被拒绝(不可用);不带 `/F` 的 `taskkill` 无法终止控制台进程,因此拆卸的 TERM 档是 `/F` 升级前的宽限等待。Windows 就绪没有精确的 stdin-wait 档:prompt-marker 快路径把 shell pid 作为伪前台进程组比较,其余由静默/计时档覆盖。 - **守护化的终端后代仍可能逃出可观察边界**:在 macOS 上,子进程如果在任何前台检查快照之前重新设定父进程,将无法再从 `node-pty` 根进程发现;在 Linux 上,调用 `setsid` 的子进程会同时离开进程树与自有终端会话。本地提供方不会新增持续进程表监视器。 - **凭据清除依赖名称启发式规则**:只匹配 `*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSPHRASE*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 - **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 From 13152903c784d9ee40b29b7108d2bdac3ea7aa16 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 00:42:40 +0800 Subject: [PATCH 011/137] fix(subprocess): satisfy the oxlint gates in the Windows inspector Routes koffi allocations through a branded NativePtr helper (koffi's TS types are any), binds the creationTime callback instead of passing the unbound method, and braces the no-op signal assertions. --- .../subprocess-local/src/windows-inspector.ts | 24 ++++++++++++++----- .../tests/windows-inspector.spec.ts | 8 +++---- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 78bea583d8..da5158b4c0 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -86,7 +86,7 @@ export class WindowsProcessInspector implements ProcessInspector { } processTree(rootPid: number): ProcessIdentity[] { - return windowsProcessTree(this.internals.snapshot(), rootPid, this.internals.creationTime) + return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.creationTime(pid)) } processSession(_sessionId: number): ProcessIdentity[] { @@ -231,6 +231,18 @@ function win32Bindings(): Win32Bindings { return cachedBindings } +/** + * Allocate koffi memory as a branded {@link NativePtr}; koffi's TS types are + * `any`, so the cast goes through `unknown` to keep the unsafe surface here. + * @param type - the koffi type to allocate. + * @param count - element count. + * @returns the branded allocation pointer. + */ +function allocNative(type: Parameters[0], count: number): NativePtr { + const value: unknown = koffi.alloc(type, count) + return value as NativePtr +} + /** Enumerate the current process table through Toolhelp32. */ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { const { PROCESSENTRY32W } = win32Structs() @@ -240,7 +252,7 @@ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { if (isInvalidHandle(snapshot)) return [] const entries: ProcessEntry[] = [] try { - const entry = koffi.alloc(PROCESSENTRY32W, 1) + const entry = allocNative(PROCESSENTRY32W, 1) koffi.encode(entry, 'uint32', PROCESSENTRY32W.size) let ok = bindings.process32FirstW(snapshot, entry) while (ok !== 0) { @@ -263,10 +275,10 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) if (isInvalidHandle(handle)) return undefined try { - const creation = koffi.alloc(FILETIME, 1) - const exit = koffi.alloc(FILETIME, 1) - const kernel = koffi.alloc(FILETIME, 1) - const user = koffi.alloc(FILETIME, 1) + const creation = allocNative(FILETIME, 1) + const exit = allocNative(FILETIME, 1) + const kernel = allocNative(FILETIME, 1) + const user = allocNative(FILETIME, 1) /* v8 ignore next -- a GetProcessTimes failure after a successful open races process exit and cannot be staged deterministically; the absent-process path is covered and the caller treats undefined as a detector miss. */ diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index b18945fc10..667c6cae46 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -134,9 +134,9 @@ win32('WindowsProcessInspector over the real koffi bindings', () => { it('reports unreadable identities for absent processes and no-ops tree signalling', () => { const inspector = createWindowsProcessInspector() expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) - expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGKILL')).not.toThrow() - expect(() => inspector.signalGroup(0x7FFFFFFF, 'SIGTERM')).not.toThrow() - expect(() => inspector.signalGroup(0, 'SIGKILL')).not.toThrow() - expect(() => inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL')).not.toThrow() + expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGKILL') }).not.toThrow() + expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGTERM') }).not.toThrow() + expect(() => { inspector.signalGroup(0, 'SIGKILL') }).not.toThrow() + expect(() => { inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL') }).not.toThrow() }) }) From 67e6d7082ecb1ee070f99c0477d4766a99aad08c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 01:11:46 +0800 Subject: [PATCH 012/137] fix: Windows-native CI findings on latest master Local run of check:ci:windows-complete (the windows-native gate) on latest master surfaced five Windows-only failures, all unreachable by current CI because the native windows job is disabled and the wine gate only covers build+site. - install-lefthook/translation-pairing-merge specs junctioned the real scripts/ and tsx package into fixtures; Windows recursive deletion (Node rmSync and git worktree remove) follows MOUNT_POINT junctions and deleted the repository's own directories mid-run. Fixtures now unlink their reparse points before any recursive removal (shared helper in scripts/test-fixture-cleanup.ts). - workflow-workerthread spawned its worker with an empty env; on Windows os.tmpdir() then degrades to the literal relative path undefined\temp, so tsx wrote its transform cache into a cwd-relative undefined/ directory inside the repo. The worker env now injects the host temp path on win32 (workerSpawnEnv, platform-parameterized and unit-tested on both arms). - workspace-context spec did not stub USERPROFILE (win32 homedir) or a set DSH_HOME, leaking the developer machine's real ~/.dsh/AGENTS.md into discovery. - ui-trajectory client-bundle spec mounted the built artifact without the remote/settingsScope provides the locale plugin needs, so the plugin never activated and no view registered. - subagent temp-fixture cleanup lacked the maxRetries Windows handle release needs under load (EPERM); added retries to the three affected specs and the fixture-cleanup helper. --- .../ui-trajectory/tests/client-bundle.spec.ts | 5 +- .../tests/workspace-context.spec.ts | 9 +++- .../subagent/tests/continuation.spec.ts | 2 +- .../subagent/tests/list-children.spec.ts | 2 +- .../tests/tool-subagent-control.spec.ts | 2 +- .../workflow-workerthread/src/host.ts | 33 +++++++++++-- .../tests/workflow-workerthread.spec.ts | 42 ++++++++++++++--- scripts/install-lefthook.spec.ts | 7 ++- scripts/test-fixture-cleanup.ts | 46 +++++++++++++++++++ scripts/translation-pairing-merge.spec.ts | 12 ++++- 10 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 scripts/test-fixture-cleanup.ts diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index edb2e3072b..eebef86099 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, describe, expect, it } from 'vitest' import { ConversationEventRegistry, ConversationViewRegistry, SlotsService, @@ -82,9 +83,11 @@ describe('tsdown client artifact', () => { // Paging is session-owned; this registration-only probe never renders the // entry, so the binding stays deliberately empty. The locale plugin backs // the locale-aware view tab label (its settings scope needs a connection - // handle). + // handle and the forwarded-event port). ctx.provide('sessions', { binding: () => undefined }) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('remote', { $on: () => () => {} } as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const locale = await import('@deepseek-ai/dsh-client-locale/client') ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void }) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index e95b30fa5b..11b820636e 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -578,10 +578,12 @@ describe('workspace context instruction discovery', () => { const root = await tempRepo() const emptyHome = await tempRepo() // Isolate the default-home fallback: blank DSH_HOME is treated as unset, and - // HOME points at an empty dir so the default ~/.dsh holds no global scope. - // Symlinks are followed, so a real ~/.dsh/AGENTS.md would otherwise leak in. + // the home dirs point at an empty dir so the default ~/.dsh holds no global + // scope. Windows homedir() reads USERPROFILE (not HOME), so both must be + // stubbed or a real ~/.dsh/AGENTS.md would otherwise leak in. vi.stubEnv('DSH_HOME', '') vi.stubEnv('HOME', emptyHome) + if (process.platform === 'win32') vi.stubEnv('USERPROFILE', emptyHome) try { const cwd = join(root, 'child') await mkdir(cwd, { recursive: true }) @@ -622,6 +624,8 @@ describe('workspace context instruction discovery', () => { try { await write(join(home, '.dsh/AGENTS.md'), 'global default rule') + // A set DSH_HOME would override the homedir default and relabel the home. + vi.stubEnv('DSH_HOME', '') vi.resetModules() vi.doMock('node:os', () => ({ homedir: () => home })) const isolated = await import('@deepseek-ai/dsh-workspace-context') @@ -629,6 +633,7 @@ describe('workspace context instruction discovery', () => { expect(files.map(file => file.displayPath)).toEqual(['~/.dsh/AGENTS.md']) } finally { + vi.unstubAllEnvs() vi.doUnmock('node:os') vi.resetModules() await rm(root, { recursive: true, force: true }) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 33f046779f..ca1652f78a 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -53,7 +53,7 @@ class GatedAdapter extends LlmAdapter { const roots: string[] = [] afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }) /** Boot the full continuable stack: loop, persistence, providers, and subagents. */ diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 7a7afb5e22..4fcd9858f1 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -28,7 +28,7 @@ type Script = ConstructorParameters[0] const roots: string[] = [] afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }) /** Boot the continuable stack with real JSONL session persistence. */ diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 4960992068..6e8aa2dfef 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -47,7 +47,7 @@ const testToolSignal = new AbortController().signal const roots: string[] = [] afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }) async function setupWith(adapter: MockAdapter | GatedAdapter) { diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 501a3555c3..b036d692e6 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -6,6 +6,7 @@ * @module @deepseek-ai/dsh-workflow-workerthread/host */ +import { tmpdir } from 'node:os' import { Worker } from 'node:worker_threads' import type { WorkerOptions } from 'node:worker_threads' import { fileURLToPath } from 'node:url' @@ -28,18 +29,42 @@ interface ChildRecord { disposal?: Promise } +/** + * The scrubbed worker environment: no ambient credentials, no loader flags. + * Windows derives `os.tmpdir()` from `TMP`/`TEMP` and falls back to the + * literal relative path `undefined\temp` when the environment is empty, so + * tsx's transform cache would land in a cwd-relative `undefined/temp` + * directory; the host's real temp path (not a credential) is injected there. + * The unbuilt shape additionally forwards `TSX_TSCONFIG_PATH` for path + * resolution. + * @param platform - host platform; overridable so tests exercise both peer arms. + * @returns the scrubbed worker environment object. + */ +export function workerSpawnEnv(platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + if (platform === 'win32') { + const tmp = tmpdir() + env.TMP = tmp + env.TEMP = tmp + } + if (process.env.TSX_TSCONFIG_PATH !== undefined) { + env.TSX_TSCONFIG_PATH = process.env.TSX_TSCONFIG_PATH + } + return env +} + /** * Resolve a built worker bundle or an unbuilt bootstrap that installs both tsx * transforms inside the worker. Both shapes clear `execArgv` and the ambient - * environment; the unbuilt shape forwards only `TSX_TSCONFIG_PATH` for path - * resolution. + * environment (the worker only sees the platform temp path and, unbuilt, + * `TSX_TSCONFIG_PATH`). * @param init - the run payload, passed as `workerData`. * @returns the entry path or URL and the Worker options to spawn it with. */ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: WorkerOptions } { /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */ if (!import.meta.url.endsWith('.ts')) { - return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: {}, execArgv: [] } } + return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: workerSpawnEnv(), execArgv: [] } } } // Resolve tsx only for unbuilt consumers and install it before importing TS. const workerEntry = new URL('./worker.ts', import.meta.url) @@ -56,7 +81,7 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: W entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), options: { workerData: init, - env: process.env.TSX_TSCONFIG_PATH === undefined ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH }, + env: workerSpawnEnv(), execArgv: [], }, } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 7646a002bc..e56254c92e 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' import { Context } from '@deepseek-ai/cordis' @@ -9,6 +10,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRu import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerWorkflowEngine, { type Config } from '../src/index.ts' +import { workerSpawnEnv } from '../src/host.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' import { SessionId } from '@deepseek-ai/dsh-session' @@ -559,24 +561,49 @@ describe('dsh-workflow-workerthread', () => { expect(result.value).toBe('fine') }) - it('the worker spawns with an EMPTY environment: an escaped script finds no ambient credentials', async () => { + it('the worker spawns with a scrubbed environment: an escaped script finds no ambient credentials', async () => { const { ctx, parent } = await setup() // A canary in the HARNESS process's env: with an inherited environment // the escape below would read it back (exactly how DEEPSEEK_API_KEY - // would leak); env: {} in the spawn options is what keeps it out. + // would leak); the worker env keeps every ambient variable out. Windows + // additionally receives the host temp path (TMP/TEMP) so `os.tmpdir()` + // inside the worker resolves instead of degrading to a cwd-relative + // `undefined\temp` (tsx writes its transform cache there). process.env.WORKFLOW_ENV_CANARY = 'leak me' try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).length } + return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).sort() } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ canary: null, keys: 0 }) + const expectedKeys = process.platform === 'win32' ? ['TEMP', 'TMP'] : [] + expect(result.value).toEqual({ canary: null, keys: expectedKeys }) } finally { delete process.env.WORKFLOW_ENV_CANARY } }) + it('workerSpawnEnv injects the host temp path on win32 and leaves the POSIX peer empty', () => { + const tmp = tmpdir() + expect(workerSpawnEnv('win32')).toEqual({ TMP: tmp, TEMP: tmp }) + expect(workerSpawnEnv('linux')).toEqual({}) + }) + + it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => { + const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + vi.stubEnv('TSX_TSCONFIG_PATH', tsconfig) + try { + expect(workerSpawnEnv('linux')).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) + expect(workerSpawnEnv('win32')).toEqual({ + TMP: tmpdir(), + TEMP: tmpdir(), + TSX_TSCONFIG_PATH: tsconfig, + }) + } finally { + vi.unstubAllEnvs() + } + }) + it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => { const { ctx, parent } = await setup() // The ACP snapshot harness runs the parent with its cwd OUTSIDE the @@ -589,10 +616,13 @@ describe('dsh-workflow-workerthread', () => { try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { keys: Object.keys(proc.env), tsconfig: proc.env.TSX_TSCONFIG_PATH } + return { keys: Object.keys(proc.env).sort(), tsconfig: proc.env.TSX_TSCONFIG_PATH } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ keys: ['TSX_TSCONFIG_PATH'], tsconfig }) + const expectedKeys = process.platform === 'win32' + ? ['TEMP', 'TMP', 'TSX_TSCONFIG_PATH'] + : ['TSX_TSCONFIG_PATH'] + expect(result.value).toEqual({ keys: expectedKeys, tsconfig }) } finally { delete process.env.TSX_TSCONFIG_PATH delete process.env.WORKFLOW_ENV_CANARY diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index 2c429bba25..c5a92a675e 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -16,6 +16,7 @@ import { tmpdir } from 'node:os' import { dirname, isAbsolute, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { removeFixtureSafely, unlinkFixtureLinks } from './test-fixture-cleanup.ts' const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url)) const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P' @@ -40,7 +41,7 @@ interface CommandResult { } afterEach(() => { - for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }) + for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture) }) function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult { @@ -282,6 +283,10 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => { expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1) const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8') + // Windows Git follows the fixture's MOUNT_POINT junctions into their real + // targets while removing a worktree; unlink them first so the removal + // cannot delete the repository's scripts/ or tsx package. + unlinkFixtureLinks(fixture.linked) git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked]) expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval) expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n') diff --git a/scripts/test-fixture-cleanup.ts b/scripts/test-fixture-cleanup.ts new file mode 100644 index 0000000000..9c58eea890 --- /dev/null +++ b/scripts/test-fixture-cleanup.ts @@ -0,0 +1,46 @@ +/** + * Junction-safe fixture cleanup for Windows. Test fixtures junction the REAL + * `scripts/`, `node_modules`, and tsx package directories so installer probes + * resolve through them; Windows recursive deletion — both Node's `rmSync` and + * Git's `worktree remove` — follows MOUNT_POINT junctions into their targets + * and would delete the repository's own directories. POSIX `unlink`/`rm` + * already remove symlinks without following them, so the walk is a no-op + * there. + */ + +import { lstatSync, readdirSync, rmSync, unlinkSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Recursively unlink every symbolic link (junction) under `path`. + * @param path - the fixture tree whose reparse points are unlinked. + */ +export function unlinkFixtureLinks(path: string): void { + const visit = (entry: string): void => { + let stat: ReturnType + try { + stat = lstatSync(entry) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw error + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + if (stat.isSymbolicLink()) unlinkSync(entry) + return + } + for (const child of readdirSync(entry)) visit(join(entry, child)) + } + visit(path) +} + +/** + * Remove one fixture tree after its junctions are unlinked (see + * {@link unlinkFixtureLinks}). Retries the removal: Windows releases child + * process and antivirus file handles asynchronously, and an unretried + * `rmSync` fails immediately with EPERM under load. + * @param path - the fixture tree to remove. + */ +export function removeFixtureSafely(path: string): void { + unlinkFixtureLinks(path) + rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) +} diff --git a/scripts/translation-pairing-merge.spec.ts b/scripts/translation-pairing-merge.spec.ts index 0ee78a11b7..32924416a6 100644 --- a/scripts/translation-pairing-merge.spec.ts +++ b/scripts/translation-pairing-merge.spec.ts @@ -1,7 +1,14 @@ /** Integration coverage for automatic and explicit pairing-record conflict resolution. */ import { execFileSync, spawnSync } from 'node:child_process' -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { + chmodSync, + mkdtempSync, + mkdirSync, + readFileSync, + symlinkSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -15,6 +22,7 @@ import { renderTranslationPairingRecord, translationPairPaths, } from './translation-pairing-record.ts' +import { removeFixtureSafely } from './test-fixture-cleanup.ts' const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url)) const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url)) @@ -28,7 +36,7 @@ interface Fixture { } afterEach(() => { - for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true }) + for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture) }) function git(fixture: Fixture, args: string[]): string { From 709912b788944627dde6cbbf2f782fdd1d3c60f7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 11:18:03 +0800 Subject: [PATCH 013/137] fix(boot): unlink stale profile fallback links instead of rmSync --- ...ink-stale-profile-fallback-links.i18n.yaml | 6 +++++ ...-12-unlink-stale-profile-fallback-links.md | 25 +++++++++++++++++++ ...-unlink-stale-profile-fallback-links.zh.md | 25 +++++++++++++++++++ packages/boot/app-boot/src/profile.ts | 6 +++-- 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.i18n.yaml new file mode 100644 index 0000000000..0e53f1c748 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.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-12-unlink-stale-profile-fallback-links.md +2026-08-12-unlink-stale-profile-fallback-links.md: 32959eb1b83bcc290d1daa4e4a020a2721be489b +2026-08-12-unlink-stale-profile-fallback-links.zh.md: 1f4da12748c9b57c12bf41a740001d5df770beb6 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md new file mode 100644 index 0000000000..32959eb1b8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.md @@ -0,0 +1,25 @@ +# Agent Note: Unlink stale profile fallback links instead of rmSync + +Status: implemented + +English | [中文](2026-08-12-unlink-stale-profile-fallback-links.zh.md) + +## Problem + +`healProfilesModuleFallback` re-points `$DSH_HOME/profiles/node_modules` entries when an installation moves, and Windows hosts keep those entries as junctions. `ensureSymlink` deleted a stale entry with `rmSync(link)`, but Node treats a junction as a directory for removal: without `recursive`, `rmSync` throws `ERR_FS_EISDIR`, so every launch from a moved installation or a second worktree crashed before booting. The `replaces a wrong symlink` unit test reproduces that crash on Windows at the exact removal call. + +## Decision + +`ensureSymlink` removes a stale link with `unlinkSync(link)`. `unlink` deletes the reparse point or symlink itself on every platform and never descends into the target, which preserves the function's fail-loud guarantee that a real directory is never deleted. The [profile-plugin-bundles decision](../architecture/2026-08-05-profile-plugin-bundles.md) keeps owning the fallback's two-anchor resolution; this note owns only the removal primitive. + +## Alternatives considered + +**`rmSync(link, { recursive: true })`.** On Node 24 this deletes the junction without following its target, but `recursive` would silently delete a real directory that replaced the link between the `lstat` guard and the removal, weakening the fail-loud contract that motivates the guard. + +**`rmdirSync(link)`.** Removes a junction on Windows as well, but it reads as directory removal for a link, and `unlinkSync` is the repository's existing junction-cleanup idiom. + +**Delete and recreate every entry unconditionally.** Correct but churns unchanged links on every launch and widens the concurrent-heal race window. + +## Consequences + +Windows launches heal moved or second-checkout installations instead of crashing with `ERR_FS_EISDIR`; POSIX behavior is unchanged because `unlinkSync` also unlinks plain symlinks. The existing `replaces a wrong symlink` test now passes on Windows where it previously reproduced the crash. Two concurrent healers deleting the same stale link still surface the second deletion as `ENOENT`, unchanged from the previous `rmSync` implementation. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md new file mode 100644 index 0000000000..1f4da12748 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-stale-profile-fallback-links.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 用 unlink 删除过期的 profile 回退链接而非 rmSync + +Status: implemented + +[English](2026-08-12-unlink-stale-profile-fallback-links.md) | 中文 + +## 问题 + +`healProfilesModuleFallback` 在安装位置迁移时会把 `$DSH_HOME/profiles/node_modules` 中的条目重新指向新目标,而 Windows 主机上这些条目是 junction。`ensureSymlink` 原先用 `rmSync(link)` 删除过期条目,但 Node 在删除时把 junction 当作目录处理:不带 `recursive` 的 `rmSync` 会抛 `ERR_FS_EISDIR`,于是从迁移后的安装或第二个 worktree 启动时,每次都会在应用引导前崩溃。`replaces a wrong symlink` 单元测试在 Windows 上正好在该删除调用处复现了这一崩溃。 + +## 决策 + +`ensureSymlink` 改用 `unlinkSync(link)` 删除过期链接。`unlink` 在所有平台上都只删除重解析点或符号链接本身、绝不进入目标目录,从而保住该函数“真实目录永远不会被删除”的大声失败保证。[profile-plugin-bundles 决策](../architecture/2026-08-05-profile-plugin-bundles.md)继续拥有回退目录的双锚点解析;本 note 只拥有“用哪个删除原语”这一决定。 + +## 考虑过的替代方案 + +**`rmSync(link, { recursive: true })`。** Node 24 上它只删 junction、不跟随目标,但 `recursive` 会在 `lstat` 守卫与删除之间链接被替换成真实目录时静默删除该目录,削弱守卫存在所依据的大声失败契约。 + +**`rmdirSync(link)`。** Windows 上同样能删 junction,但它读起来像“删目录”,而 `unlinkSync` 才是仓库现有的 junction 清理惯例。 + +**无条件删除并重建所有条目。** 正确,但每次启动都翻动未变化的链接,并扩大并发修复的竞态窗口。 + +## 后果 + +Windows 启动现在可以修复迁移后的安装或第二个 checkout,而不是以 `ERR_FS_EISDIR` 崩溃;POSIX 行为不变,因为 `unlinkSync` 同样能 unlink 普通符号链接。现有的 `replaces a wrong symlink` 测试在 Windows 上从复现崩溃变为通过。两个并发 healer 删除同一过期链接时,第二次删除仍会以 `ENOENT` 浮现,与原先的 `rmSync` 实现一致。 diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 11f22c6df7..efa60eb78e 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -24,7 +24,7 @@ import { createRequire } from 'node:module' import { - existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' import { basename, dirname, join } from 'node:path' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' @@ -182,7 +182,9 @@ function ensureSymlink(link: string, target: string): void { throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`) } if (readlinkSync(link) === target) return - rmSync(link) + // unlink deletes the reparse point itself on Windows too; rmSync treats a + // junction as a directory and throws EISDIR unless recursive. + unlinkSync(link) } try { symlinkSync(target, link, 'junction') From e37af006c5c8b02e4a4c4302f994ee0f318be89e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 11:19:01 +0800 Subject: [PATCH 014/137] test(app-boot): unlink hmr alias junctions before removing the target --- packages/boot/app-boot/tests/hmr-config.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index c248643130..5f52ab5e2a 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -62,7 +62,7 @@ describe('HMR exact config paths', () => { expect(cacheHas).toHaveBeenCalledWith(expected) } finally { await ctx.fiber.dispose() - rmSync(alias, { force: true }) + unlinkSync(alias) rmSync(target, { recursive: true, force: true }) } }) @@ -78,7 +78,7 @@ describe('HMR exact config paths', () => { .rejects.toThrow('config path already registered') } finally { await ctx.fiber.dispose() - rmSync(alias, { force: true }) + unlinkSync(alias) rmSync(target, { recursive: true, force: true }) } }) From 874d7c4f78578790b7ba2e27da92cff216d0b282 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 11:32:03 +0800 Subject: [PATCH 015/137] docs: add junction-safe unlink rule to defensive patterns --- docs/defensive-patterns.i18n.yaml | 4 ++-- docs/defensive-patterns.md | 4 ++++ docs/defensive-patterns.zh.md | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml index 18b28ca58c..caf07bb52f 100644 --- a/docs/defensive-patterns.i18n.yaml +++ b/docs/defensive-patterns.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/defensive-patterns.md -defensive-patterns.md: 368c9876f1a4e7042b003f6acfb30af3b2daf402 -defensive-patterns.zh.md: c7d4c1bf37ef17947913ac4011624d04ffd8c1a3 +defensive-patterns.md: b396b6b88f37b3ccea25321fb25534087911f8d4 +defensive-patterns.zh.md: de4c1d7f17cdde48596460bb4f76a4b0f6667c5a diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md index 368c9876f1..b396b6b88f 100644 --- a/docs/defensive-patterns.md +++ b/docs/defensive-patterns.md @@ -27,3 +27,7 @@ A user-supplied listener that throws must not reject the promise it runs inside ## Never hand untrusted output the ambient environment or predictable paths Spawned commands get a scrubbed env (drop `*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASSWORD*`) so harness credentials cannot leak into output, `env`, or spill files. Temp/spill files use a private (0700) dir, random names, and exclusive owner-only opens (`'wx'`, `0o600`) — predictable world-readable paths invite symlink races and disclosure. + +## Unlink link-shaped paths + +A path that may be a symlink or Windows junction is removed with `lstatSync().isSymbolicLink()` then `unlinkSync`: unlink deletes only the link and refuses a real directory, so it never follows the link into its target. Windows `rmSync(link)` throws `ERR_FS_EISDIR` on a junction; recursive deletion may descend through one into its target. Reserve recursive `rmSync` for known real directories. diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md index c7d4c1bf37..de4c1d7f17 100644 --- a/docs/defensive-patterns.zh.md +++ b/docs/defensive-patterns.zh.md @@ -27,3 +27,7 @@ ## 绝不将环境变量或可预测路径暴露给不可信输出 启动的命令应使用经过清理的环境变量,移除名称匹配 `*KEY*`、`*SECRET*`、`*TOKEN*` 或 `*PASSWORD*` 的项,防止 harness 凭证通过命令输出、`env` 或 spill 文件泄漏。临时文件和 spill 文件应放在权限为 0700 的私有目录中,使用随机文件名,并以独占且仅所有者可访问的方式打开(`'wx'`、`0o600`);可预测且全局可读的路径会引发符号链接竞态和信息泄露。 + +## 用 unlink 删除链接形态的路径 + +可能是符号链接或 Windows junction 的路径,应先用 `lstatSync().isSymbolicLink()` 判断,再用 `unlinkSync` 删除:unlink 只删除链接本身并拒绝真实目录,因此绝不会跟随链接进入其目标。Windows 上对 junction 调用 `rmSync(link)` 会抛 `ERR_FS_EISDIR`;递归删除可能穿过 junction 进入其目标。真实目录才使用带 `recursive` 的 `rmSync`。 From 772b5a2ec81bfc70b812b56e46ecf0bdaa9ca047 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 14:04:45 +0800 Subject: [PATCH 016/137] fix(pwsh): resolve Store app execution aliases --- ...08-12-resolve-store-pwsh-aliases.i18n.yaml | 6 +++++ .../2026-08-12-resolve-store-pwsh-aliases.md | 23 +++++++++++++++++++ ...026-08-12-resolve-store-pwsh-aliases.zh.md | 23 +++++++++++++++++++ packages/bash/pwsh-local/src/resolve.ts | 17 ++++++++++++-- .../bash/pwsh-local/tests/executor.spec.ts | 20 +++++++++++++--- 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.i18n.yaml new file mode 100644 index 0000000000..140c7b9ae7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.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-12-resolve-store-pwsh-aliases.md +2026-08-12-resolve-store-pwsh-aliases.md: 20fe58e15e75462dc0a9ba76c7a1a94939f8a004 +2026-08-12-resolve-store-pwsh-aliases.zh.md: bbfa4616127a9dbdb6609fe2973283663de55b31 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md new file mode 100644 index 0000000000..20fe58e15e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.md @@ -0,0 +1,23 @@ +# Agent Note: Resolve Microsoft Store pwsh aliases + +Status: implemented + +English | [中文](2026-08-12-resolve-store-pwsh-aliases.zh.md) + +## Problem + +`resolvePwshPath` documented that Microsoft Store installs resolve through PATH, but its existence probe was `existsSync`, which stats a candidate and therefore follows reparse points. The Store's `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` is an app execution alias whose target directory ACL refuses stat (EACCES), so `existsSync` missed it and resolution silently fell through to Windows PowerShell 5.1 on hosts whose only PowerShell 7 is a Store install. + +## Decision + +`candidateExists` accepts a candidate that stats as a file or that lstat sees as a link-shaped reparse point, and `resolvePwshPath` uses it. Spawning the alias path works because CreateProcess resolves app execution aliases. A dangling link-shaped candidate is accepted so a broken pwsh fails loudly at spawn instead of silently downgrading to 5.1. + +## Alternatives considered + +**Probe the WindowsApps package directory directly.** The Store package path is versioned and ACL-hidden; hard-coding it duplicates packaging knowledge that PATH plus the alias already owns. + +**Keep the 5.1 fallback for stat failures.** Rejected: it silently runs a different shell than the one installed, which is the defect this note fixes. + +## Consequences + +Store-installed PowerShell 7 now resolves ahead of the 5.1 fallback on Windows; real-file candidates and non-Windows behavior are unchanged. The dangling-symlink unit test pins the stat/lstat split on every platform. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md new file mode 100644 index 0000000000..bbfa461612 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-resolve-store-pwsh-aliases.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 解析 Microsoft Store 的 pwsh 别名 + +Status: implemented + +[English](2026-08-12-resolve-store-pwsh-aliases.md) | 中文 + +## 问题 + +`resolvePwshPath` 声称 Store 安装经 PATH 解析,但它的存在性探测用的是 `existsSync`,会对候选做 stat、从而跟随重解析点。Store 的 `%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe` 是 app execution alias,其目标目录的 ACL 拒绝 stat(EACCES),于是 `existsSync` 看不到它,解析静默落到 Windows PowerShell 5.1——在这类「唯一的 PowerShell 7 是 Store 安装」的机器上就用了错误的 shell。 + +## 决策 + +`candidateExists` 接受「stat 为文件」或「lstat 为链接形态重解析点」的候选,`resolvePwshPath` 改用它。spawn 别名路径可以工作,因为 CreateProcess 会解析 app execution alias。悬空的链接形态候选同样被接受,让损坏的 pwsh 在 spawn 时响亮失败,而不是静默降级到 5.1。 + +## 考虑过的替代方案 + +**直接探测 WindowsApps 包目录。** Store 包路径带版本且被 ACL 隐藏;硬编码它只是重复了 PATH 加别名已经拥有的打包知识。 + +**对 stat 失败继续走 5.1 回退。** 否决:它静默运行了一个并非所装的 shell,这正是本 note 修复的缺陷。 + +## 后果 + +Windows 上 Store 安装的 PowerShell 7 现在先于 5.1 回退被解析;普通文件候选和非 Windows 平台行为不变。悬空 symlink 单元测试在全部平台上钉住 stat/lstat 的分裂行为。 diff --git a/packages/bash/pwsh-local/src/resolve.ts b/packages/bash/pwsh-local/src/resolve.ts index c6ded2f883..f090c2b4c1 100644 --- a/packages/bash/pwsh-local/src/resolve.ts +++ b/packages/bash/pwsh-local/src/resolve.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-pwsh-local/resolve */ -import { existsSync } from 'node:fs' +import { existsSync, lstatSync } from 'node:fs' import { join } from 'node:path' /** @@ -36,6 +36,19 @@ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string return candidates } +/** Whether a candidate can be spawned: a real file or a link-shaped reparse point. */ +function candidateExists(candidate: string): boolean { + if (existsSync(candidate)) return true + // Microsoft Store app execution aliases are reparse points whose target + // ACL refuses stat(), so existsSync misses them; lstat sees the link + // itself and CreateProcess resolves it when the executor spawns. + try { + return lstatSync(candidate).isSymbolicLink() + } catch { + return false + } +} + /** * Resolve the pwsh executable this executor spawns. * @param configured - an explicit `pwshPath` config value, trusted as-is. @@ -53,7 +66,7 @@ export function resolvePwshPath( if (configured !== undefined && configured.length > 0) return configured if (platform === 'win32') { for (const candidate of candidatePwshPaths(env)) { - if (existsSync(candidate)) return candidate + if (candidateExists(candidate)) return candidate } } return 'pwsh' diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index f84cc461d4..f6876d05c8 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -9,7 +9,7 @@ * writes CRLF on Windows, so exact text assertions normalize line endings. */ -import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' @@ -126,6 +126,18 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: join(dir, 'empty'), SystemRoot: join(dir, 'no-windows') }, 'win32')) .toBe('pwsh') }) + + it('accepts a link-shaped PATH candidate whose target cannot be stat-ed', () => { + // Store app execution aliases stat as EACCES but lstat as a link; a + // dangling symlink reproduces that split on every platform. + const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-link-')) + const store = join(dir, 'store') + mkdirSync(store, { recursive: true }) + const link = join(store, 'pwsh.exe') + symlinkSync(join(dir, 'no-such-target.exe'), link) + expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32')) + .toBe(link) + }) }) describe('spawn construction (pure, every platform)', () => { @@ -298,8 +310,10 @@ describe.skipIf(!hasPwsh)('PwshLocalExecutor.start (background process handles)' it('start returns immediately with a running handle that settles as completed', async () => { const { bash } = await setup() const before = Date.now() - const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 200; Write-Output done' })) - expect(Date.now() - before).toBeLessThan(150) + // The sleep outlasts any realistic spawn latency, so returning while the + // child still sleeps proves start() does not wait for completion. + const proc = bash.start(bash.resolve({ command: 'Start-Sleep -Milliseconds 2000; Write-Output done' })) + expect(Date.now() - before).toBeLessThan(1000) expect(proc.status).toBe('running') await proc.done expect(proc.status).toBe('completed') From 06891c762887c6ff55b81f95ea1525169e13918a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 14:04:57 +0800 Subject: [PATCH 017/137] test(claude-code): isolate ambient Anthropic model env --- .../tests/real-product.spec.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 9320cb0e7c..dbf8f0d7d3 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -17,7 +17,7 @@ import type { SDKSystemMessage, } from '@anthropic-ai/claude-agent-sdk' import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' @@ -87,6 +87,22 @@ const roots: string[] = [] const fixtures: MessagesFixture[] = [] const contexts: Context[] = [] +// Ambient Anthropic model env leaks into the real CLI and overrides the +// fixture settings.json on developer machines; delete it for this file and +// restore it after, like the workspace-context USERPROFILE isolation. +const ambientAnthropicModel = process.env.ANTHROPIC_MODEL +const ambientAnthropicSmallFastModel = process.env.ANTHROPIC_SMALL_FAST_MODEL + +beforeAll(() => { + delete process.env.ANTHROPIC_MODEL + delete process.env.ANTHROPIC_SMALL_FAST_MODEL +}) + +afterAll(() => { + if (ambientAnthropicModel !== undefined) process.env.ANTHROPIC_MODEL = ambientAnthropicModel + if (ambientAnthropicSmallFastModel !== undefined) process.env.ANTHROPIC_SMALL_FAST_MODEL = ambientAnthropicSmallFastModel +}) + afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) From d58bd913563a245019626139e17c45b28579f2a1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 15:11:05 +0800 Subject: [PATCH 018/137] fix(workflow): forward the tsconfig pin only in the unbuilt worker --- .../workflow/workflow-workerthread/src/host.ts | 13 ++++++++----- .../tests/workflow-workerthread.spec.ts | 17 ++++++----------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index b036d692e6..d17059ad8f 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -38,18 +38,21 @@ interface ChildRecord { * The unbuilt shape additionally forwards `TSX_TSCONFIG_PATH` for path * resolution. * @param platform - host platform; overridable so tests exercise both peer arms. + * @param tsconfigPath - the tsconfig pin to forward; only the unbuilt caller + * passes one, so the built worker never observes the host's pin. * @returns the scrubbed worker environment object. */ -export function workerSpawnEnv(platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv { +export function workerSpawnEnv( + platform: NodeJS.Platform = process.platform, + tsconfigPath: string | undefined = undefined, +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} if (platform === 'win32') { const tmp = tmpdir() env.TMP = tmp env.TEMP = tmp } - if (process.env.TSX_TSCONFIG_PATH !== undefined) { - env.TSX_TSCONFIG_PATH = process.env.TSX_TSCONFIG_PATH - } + if (tsconfigPath !== undefined) env.TSX_TSCONFIG_PATH = tsconfigPath return env } @@ -81,7 +84,7 @@ function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: W entry: new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), options: { workerData: init, - env: workerSpawnEnv(), + env: workerSpawnEnv(undefined, process.env.TSX_TSCONFIG_PATH), execArgv: [], }, } diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index e56254c92e..de26d822f7 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -591,17 +591,12 @@ describe('dsh-workflow-workerthread', () => { it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => { const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) - vi.stubEnv('TSX_TSCONFIG_PATH', tsconfig) - try { - expect(workerSpawnEnv('linux')).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) - expect(workerSpawnEnv('win32')).toEqual({ - TMP: tmpdir(), - TEMP: tmpdir(), - TSX_TSCONFIG_PATH: tsconfig, - }) - } finally { - vi.unstubAllEnvs() - } + expect(workerSpawnEnv('linux', tsconfig)).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) + expect(workerSpawnEnv('win32', tsconfig)).toEqual({ + TMP: tmpdir(), + TEMP: tmpdir(), + TSX_TSCONFIG_PATH: tsconfig, + }) }) it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => { From db05dad2c7bf929d5c070d5c936a845247b3df4c Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 15:11:20 +0800 Subject: [PATCH 019/137] fix(pwsh): accept link-shaped candidates and sync the README contract --- packages/bash/pwsh-local/README.i18n.yaml | 4 ++-- packages/bash/pwsh-local/README.md | 2 +- packages/bash/pwsh-local/README.zh.md | 2 +- packages/bash/pwsh-local/src/resolve.ts | 20 ++++++++++++------- .../bash/pwsh-local/tests/executor.spec.ts | 11 ++++++++++ 5 files changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 46929dd0f2..a1ecf86130 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-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/bash/pwsh-local/README.md -README.md: 76f3071dc1049e2ea5929d5990ee0cb526ef702e -README.zh.md: e773e0e83e81ffa311bd555b7a75433ba22dfd22 +README.md: 234c1e45cff2b31b93665af582cdf67ce9459f4c +README.zh.md: 8cf750c47b68b835222067652e377d0de977516f diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index 76f3071dc1..234c1e45cf 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -30,7 +30,7 @@ The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantic - **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. - **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.bash`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section. - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. -- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem. +- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking each candidate with an lstat probe that accepts a real file or a link-shaped reparse point (a Store app execution alias stat-fails against its target's ACL, but lstat sees the alias itself); elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem. - **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index e773e0e83e..8cf750c47b 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -30,7 +30,7 @@ - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 - **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.bash` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 -- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 +- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一用 lstat 探测检查(接受真实文件或链接形态的重解析点:Store 的 app execution alias 对其目标 stat 会因 ACL 失败,但 lstat 能看到别名本身);其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 - **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 diff --git a/packages/bash/pwsh-local/src/resolve.ts b/packages/bash/pwsh-local/src/resolve.ts index f090c2b4c1..191abba963 100644 --- a/packages/bash/pwsh-local/src/resolve.ts +++ b/packages/bash/pwsh-local/src/resolve.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-pwsh-local/resolve */ -import { existsSync, lstatSync } from 'node:fs' +import { lstatSync } from 'node:fs' import { join } from 'node:path' /** @@ -36,15 +36,21 @@ export function candidatePwshPaths(env: NodeJS.ProcessEnv = process.env): string return candidates } -/** Whether a candidate can be spawned: a real file or a link-shaped reparse point. */ +/** + * Whether a candidate can be spawned. lstat opens the entry itself instead of + * following reparse points, so it sees the Store app execution alias where + * stat hits the target's ACL (EACCES); Node reports that alias as a symlink + * on current releases and as a plain file on older ones, and CreateProcess + * resolves either shape. A real directory never matches. + */ function candidateExists(candidate: string): boolean { - if (existsSync(candidate)) return true - // Microsoft Store app execution aliases are reparse points whose target - // ACL refuses stat(), so existsSync misses them; lstat sees the link - // itself and CreateProcess resolves it when the executor spawns. try { - return lstatSync(candidate).isSymbolicLink() + const stat = lstatSync(candidate) + return stat.isFile() || stat.isSymbolicLink() } catch { + // ENOENT (the candidate vanished between listing and probing) is the only + // expected failure; any other error names an unspawnable path, so false + // is the safe answer for it too. return false } } diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index f6876d05c8..1fe555b5e6 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -138,6 +138,17 @@ describe('resolvePwshPath and candidatePwshPaths (pure, every platform)', () => expect(resolvePwshPath(undefined, { ProgramFiles: join(dir, 'missing'), PATH: store }, 'win32')) .toBe(link) }) + + it('skips a directory candidate and falls through to the PATH-resolution default', () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-resolve-dir-')) + const store = join(dir, 'store') + mkdirSync(join(store, 'pwsh.exe'), { recursive: true }) + expect(resolvePwshPath(undefined, { + ProgramFiles: join(dir, 'missing'), + PATH: store, + SystemRoot: join(dir, 'no-windows'), + }, 'win32')).toBe('pwsh') + }) }) describe('spawn construction (pure, every platform)', () => { From 9592842df68df602cc63cdf2c4a092707ca5854a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 15:11:38 +0800 Subject: [PATCH 020/137] docs: record the junction-safe fixture teardown decision --- ...-fixture-junctions-before-delete.i18n.yaml | 6 +++++ ...-unlink-fixture-junctions-before-delete.md | 23 +++++++++++++++++++ ...link-fixture-junctions-before-delete.zh.md | 23 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.i18n.yaml new file mode 100644 index 0000000000..160f947daa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.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-12-unlink-fixture-junctions-before-delete.md +2026-08-12-unlink-fixture-junctions-before-delete.md: 4514a33d728866b817f4e9c1393f16c07976ed45 +2026-08-12-unlink-fixture-junctions-before-delete.zh.md: 3c212c052ab0303ccb8d31d2b310a365a1d8cc99 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md new file mode 100644 index 0000000000..4514a33d72 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.md @@ -0,0 +1,23 @@ +# Agent Note: Unlink fixture junctions before recursive deletion + +Status: implemented + +English | [中文](2026-08-12-unlink-fixture-junctions-before-delete.zh.md) + +## Problem + +The install-lefthook and translation-pairing fixtures junction the repository's real `scripts/`, `node_modules`, and tsx package directories into fixture trees so installer probes resolve through them. Windows recursive deletion can treat a junction (a MOUNT_POINT reparse point) as a directory and follow it into its target; Git's `worktree remove` did exactly that and deleted the repository's tracked `scripts/` and tsx package (the incident's instrumentation pinned the deletion to that step). A fixture cleanup that trusts its deleter therefore deletes the repository's own sources instead of the fixture. + +## Decision + +`scripts/test-fixture-cleanup.ts` owns junction-safe fixture teardown: `unlinkFixtureLinks` walks a tree and unlinks every reparse point before `removeFixtureSafely` removes the now link-free tree (with Windows async-handle retries). Every affected `afterEach` and the pre-`worktree remove` hook call it. The general rule lives in `docs/defensive-patterns.md`: remove link-shaped paths with unlink, reserve recursive `rmSync` for known real directories. + +## Alternatives considered + +**Trust recursive deletion alone.** Rejected: whether a given deleter follows junctions is tool- and version-dependent, and one path through `git worktree remove` already destroyed tracked files; no cleanup may bet the repository on that behavior. + +**Copy instead of junctioning the real directories.** Rejected: the fixtures exist to probe the real installer paths through their real contents, so copies would stop exercising the boundary under test. + +## Consequences + +Fixture teardown can no longer reach repository sources through junctions. The extra walk is one lstat/unlink pass over small fixture trees. The data-destroying defect now has its durable why beside the defensive-patterns rule, and the helper is the shared teardown path for future junction fixtures. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md new file mode 100644 index 0000000000..3c212c052a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-unlink-fixture-junctions-before-delete.zh.md @@ -0,0 +1,23 @@ +# Agent Note: 递归删除前先解链 fixture junction + +Status: implemented + +[English](2026-08-12-unlink-fixture-junctions-before-delete.md) | 中文 + +## 问题 + +install-lefthook 与 translation-pairing 的 fixture 把仓库真实的 `scripts/`、`node_modules` 和 tsx 包目录用 junction 链进 fixture 树,让 installer 探测能穿透解析。Windows 的递归删除可能把 junction(MOUNT_POINT 重解析点)当作目录并跟随进其目标;Git 的 `worktree remove` 正是这样删掉了仓库被跟踪的 `scripts/` 和 tsx 包(事故的插桩把删除定位到这一步)。因此,信任删除器的 fixture 清理删掉的是仓库自己的源码,而不是 fixture。 + +## 决策 + +`scripts/test-fixture-cleanup.ts` 拥有 junction 安全的 fixture 拆除:`unlinkFixtureLinks` 先遍历并解链所有重解析点,`removeFixtureSafely` 再删除已无链接的树(带 Windows 异步句柄重试)。所有受影响的 `afterEach` 和 `worktree remove` 前的钩子都调用它。通用规则记录在 `docs/defensive-patterns.md`:链接形态的路径用 unlink 删除,递归 `rmSync` 只留给确知为真实目录的路径。 + +## 考虑过的替代方案 + +**只信任递归删除。** 否决:特定删除器是否跟随 junction 随工具和版本而异,而 `git worktree remove` 这一条路径已经摧毁过被跟踪文件;任何清理都不该拿仓库去赌这个行为。 + +**复制而不是 junction 真实目录。** 否决:fixture 的意义就是用真实内容探测真实 installer 路径,复制品会失去被测边界。 + +## 后果 + +fixture 拆除不再能穿过 junction 触及仓库源码。额外开销只是对小型 fixture 树的一趟 lstat/unlink。这个摧毁数据的缺陷现在在 defensive-patterns 规则旁有了持久化的原因,helper 也是未来所有 junction fixture 共享的拆除路径。 From d992eb116e9370848947e23b45768ba0b1e346c7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 16:10:45 +0800 Subject: [PATCH 021/137] fix(workflow): use optional syntax for the tsconfig pin parameter --- packages/workflow/workflow-workerthread/src/host.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index d17059ad8f..2273e6ca1b 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -44,7 +44,7 @@ interface ChildRecord { */ export function workerSpawnEnv( platform: NodeJS.Platform = process.platform, - tsconfigPath: string | undefined = undefined, + tsconfigPath?: string, ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {} if (platform === 'win32') { From d05351a270f830fa9d7f9f9ffb8f54f1b37b0386 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 01:11:20 +0800 Subject: [PATCH 022/137] docs(pty): register the persistent pwsh tool in the catalogs Adds tool-pwsh-persistent to the tool-catalog manifest, regenerates docs/tool-catalog.md and docs/config-catalog.md (the pty-local shellDialect config), and fixes the persistent-pty note's cross-link level to the implemented pwsh note. --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +-- .../2026-07-16-persistent-pty-sessions.md | 2 +- .../2026-07-16-persistent-pty-sessions.zh.md | 2 +- docs/config-catalog.md | 31 +++++++++++++++++-- docs/tool-catalog.md | 26 ++++++++++++++++ scripts/gen-tool-catalog.ts | 14 +++++++++ 6 files changed, 72 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index eab1b8987e..a15b09cc7c 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: fa9d90e3ded97b279c5cad7c8b5733333403beaf -2026-07-16-persistent-pty-sessions.zh.md: f801f4d0728452b2a5acf75c5bcbef0d9e1e6901 +2026-07-16-persistent-pty-sessions.md: 252af19ac2cc8ca29509e189ab07d9d147feef63 +2026-07-16-persistent-pty-sessions.zh.md: e63cacde98ec827b4ed1fed4eba39d228bdaf22a diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index fa9d90e3de..252af19ac2 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -134,7 +134,7 @@ The package ships concise tool guidance explaining persistent state, owner isola - Declarative per-agent startup requires an agent-setup composition point; plugin-load global sessions remain prohibited. - Session restoration across harness-process loss requires an out-of-process owner and a versioned protocol. - Network-egress policy and rollback of external side effects are broader than PTY and remain separate security work. -- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../../architecture/2026-08-11-pwsh-persistent-pty.md). +- Windows/ConPTY sessions run through the subprocess-local Windows inspector (Toolhelp32 identities, pseudo foreground groups, taskkill teardown) and the `pty-local` pwsh dialect; see the [pwsh persistent tool note](../architecture/2026-08-11-pwsh-persistent-pty.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index f801f4d072..e63cacde98 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -134,7 +134,7 @@ plugins: - 声明式 per-agent 启动需要 agent-setup 组合点;仍然禁止插件加载期全局会话。 - harness 进程丢失后的会话恢复需要进程外 owner 和版本化协议。 - 网络出口策略与外部副作用回滚超出 PTY 范围,继续作为独立安全工作。 -- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../../architecture/2026-08-11-pwsh-persistent-pty.md)。 +- Windows/ConPTY 会话经由 subprocess-local 的 Windows inspector(Toolhelp32 身份、伪前台进程组、taskkill 拆卸)与 `pty-local` 的 pwsh 方言运行;见 [pwsh 持久工具 note](../architecture/2026-08-11-pwsh-persistent-pty.md)。 ## 备选方案 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 01c942493a..44d6a8c8cf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1229,9 +1229,11 @@ Requires: `pty` · `sandboxPolicy` · `subprocess` export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -1259,9 +1261,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -Source: [`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) +Source: [`packages/pty/pty-local/src/config.ts:10`](../packages/pty/pty-local/src/config.ts) ## `@deepseek-ai/dsh-pwsh-local` @@ -2232,6 +2237,26 @@ export interface Config { Source: [`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +Requires: `tools` · `pty` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +Source: [`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index eeccb974b8..9404a74947 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt`, `ctx.bashEnv`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.bash`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.tasks` runtime and are collected/stopped through the `task_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-bash-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@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-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect 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 API. | | `@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. | @@ -351,6 +352,31 @@ Source: [`packages/pty/tool-bash-persistent/src/index.ts`](../packages/pty/tool- One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +Source: [`packages/pty/tool-pwsh-persistent/src/index.ts`](../packages/pty/tool-pwsh-persistent/src/index.ts) + +One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 73a87f8213..0a836b2f1c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -43,6 +43,7 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent' +import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search' @@ -273,6 +274,19 @@ const TOOL_PACKAGES: ToolPackage[] = [ note: 'One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description.', }, + { + pkg: '@deepseek-ai/dsh-tool-pwsh-persistent', + dir: 'tool-pwsh-persistent', + source: 'packages/pty/tool-pwsh-persistent/src/index.ts', + requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'], + writes: ['tool/call', 'PTY shell state', 'tool/result'], + async mount(ctx) { + await ctx.plugin(PtyService) + await ctx.plugin(ToolPwshPersistent) + }, + note: + 'One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description.', + }, { pkg: '@deepseek-ai/dsh-tool-str-replace-editor', dir: 'tool-str-replace-editor', From 2b839f8d7b7884aa9313319bd8758c9ea5c78a52 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 01:20:31 +0800 Subject: [PATCH 023/137] docs(i18n): sync the catalog Chinese counterparts for the new tool Mirrors the tool-pwsh-persistent catalog section and the pty-local shellDialect config into the reviewed Chinese counterparts and re-records both pairing sidecars. --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.zh.md | 31 ++++++++++++++++++++++++++++--- docs/tool-catalog.i18n.yaml | 4 ++-- docs/tool-catalog.zh.md | 26 ++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2b0f494ca6..60443c8598 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: 01c942493ad81eb978ff269bee544bbb1560c819 -config-catalog.zh.md: 4be30ea40432e71e2fc53fd17fae107fd631fa06 +config-catalog.md: 44d6a8c8cfcf9faaa9b6c88e1df460103ce11939 +config-catalog.zh.md: a5944796095aef91fe87e927174f05f68ae2bbcb diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4be30ea404..a594479609 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1231,9 +1231,11 @@ export interface PlanModeConfig { export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -1261,9 +1263,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -来源:[`packages/pty/pty-local/src/config.ts:6`](../packages/pty/pty-local/src/config.ts) +来源:[`packages/pty/pty-local/src/config.ts:10`](../packages/pty/pty-local/src/config.ts) ## `@deepseek-ai/dsh-pwsh-local` @@ -2233,6 +2238,26 @@ export interface Config { 来源:[`packages/bash/tool-pwsh/src/index.ts:52`](../packages/bash/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +需要:`tools` · `pty` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +来源:[`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` 需要:`tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 2acc82ba4a..34d2789889 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: eeccb974b86fddf782f0970e3fb3b63805a007b6 -tool-catalog.zh.md: 485f5e819647c860c0dbe021a4914a49badcec2a +tool-catalog.md: 9404a74947e7706980b6fee971316a1ca18da1d4 +tool-catalog.zh.md: c2ece838449c17928d0eadb3caa6819b45a73d8b diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 485f5e8196..c2ece83844 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,6 +24,7 @@ | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.bash`、`ctx.systemPrompt`、`ctx.bashEnv`、`ctx.tasks at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.bash` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.tasks` 运行时,并通过 `task_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-bash-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@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-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.pty`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 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`、`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 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | @@ -353,6 +354,31 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +在持久 PowerShell shell 中运行命令。包括当前目录和已导出环境变量在内的状态会在此 agent 的多次调用之间保留。 + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +来源:[`packages/pty/tool-pwsh-persistent/src/index.ts`](../packages/pty/tool-pwsh-persistent/src/index.ts) + +一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` From db208953b1728686b39e57a0980aa8f32de817cb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 12 Aug 2026 23:57:35 +0800 Subject: [PATCH 024/137] fix(subprocess): keep the windows-inspector Linux coverage exemption and align the pwsh note with master's windows test structure --- .../architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml | 4 ++-- .../architecture/2026-08-11-pwsh-persistent-pty.md | 6 +++--- .../architecture/2026-08-11-pwsh-persistent-pty.zh.md | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index c74339813b..59ea231ed3 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 51988586a6c260d528e140ab718eb302355c7314 -2026-08-11-pwsh-persistent-pty.zh.md: e753c050827b216330ac9bce0258f9b59d866999 +2026-08-11-pwsh-persistent-pty.md: 7d4fe5e21fd4f9d96cfdf54dfbc6273f8aab3b45 +2026-08-11-pwsh-persistent-pty.zh.md: b1bb90218e15617d4445936abe1be19a537ef9f7 diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 51988586a6..7d4fe5e21f 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 ` ### Testing -The subprocess-local and pty-local suites now run on Windows: bash-shaped fixtures self-skip through platform gates, the spawn/terminal suites translate their simple shell commands to node one-liners and exercise injected POSIX group paths, and the koffi-backed inspector joins the windows-only coverage exclusions on Linux while the windows-native lane enforces its 100% coverage. The tool suite mirrors `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. +The Windows test surface follows master's exemption structure: pty-local and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. ## Alternatives considered @@ -52,9 +52,9 @@ The subprocess-local and pty-local suites now run on Windows: bash-shaped fixtur ## Consequences -**Windows became a first-class persistent-shell host.** The pty family now runs, tests, and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. +**Windows became a first-class persistent-shell host.** The persistent pwsh stack runs and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. -**The windows-native coverage flip is a standing commitment.** subprocess-local and pty-local sources are coverage-required on win32; their suites run there (with platform gates and node-translated commands) and must keep 100% coverage on the windows-native lane. +**Windows coverage keeps master's exemption structure.** subprocess-local and pty-local sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. **Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index e753c05082..b1bb90218e 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell ### 测试 -subprocess-local 与 pty-local 套件现在在 Windows 上运行:bash 形态 fixture 通过平台门控自跳过,spawn/terminal 套件把简单 shell 命令翻译为 node 单行并覆盖注入的 POSIX 组路径,koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免,windows-native 车道强制执行其 100% 覆盖。工具套件镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 +Windows 测试面沿用 master 的豁免结构:pty-local 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 ## 备选方案 @@ -52,9 +52,9 @@ subprocess-local 与 pty-local 套件现在在 Windows 上运行:bash 形态 f ## 后果 -**Windows 成为一等公民的持久 shell 宿主。** pty 家族现在在 windows-native 车道上运行、测试并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 +**Windows 成为一等公民的持久 shell 宿主。** 持久 pwsh 栈在 windows-native 车道上运行并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 -**windows-native 覆盖翻转是常驻承诺。** subprocess-local 与 pty-local 源码在 win32 上受覆盖约束;它们的套件在那里运行(带平台门控与 node 翻译命令),并必须在 windows-native 车道保持 100% 覆盖。 +**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 pty-local 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 **Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 From 974c340bb12eae225327bae2a13dd3d20da54ba0 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 00:12:13 +0800 Subject: [PATCH 025/137] fix(pty): close the exit race between send settlement and the next poll in both persistent shell tools --- .../pty/tool-bash-persistent/src/index.ts | 52 ++++++++++++++---- .../tool-bash-persistent/tests/tools.spec.ts | 30 +++++++++++ .../pty/tool-pwsh-persistent/src/index.ts | 54 +++++++++++++++---- .../tool-pwsh-persistent/tests/tools.spec.ts | 30 +++++++++++ 4 files changed, 146 insertions(+), 20 deletions(-) diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index fa2a965231..3116314573 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -197,6 +197,36 @@ function renderShellExitStatus( return appendStatusMarker(content, marker) } +/** + * Render the exited-session result, reset the owner's shell, and reset the + * message that tells the model the next call starts fresh. + * @param shells - the owner-scoped registry to reset. + * @param status - the exited session status (exit code and signal). + * @returns the complete model-facing result. + */ +async function respondToSessionExit( + ctx: Context, + shells: PersistentShells, + owner: Agent, + id: PtySessionId, + status: { exitCode: number | null; signal: NodeJS.Signals | null }, + marker: CommandMarkers, + fallback: string, + fallbackTruncated: boolean, + config: ResolvedConfig, +): Promise { + const snapshot = retainedScrollback(ctx, owner, id) + await shells.reset(owner, 'persistent bash shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), + status.exitCode, + status.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') +} + function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { const pending = new WeakMap>() const live = new Map() @@ -286,6 +316,15 @@ async function executeCommand( let fallbackTruncated = false while (true) { + // The shell may flip to exited between iterations (a fast `exit` can + // settle the previous send while its exit event is still in flight); + // re-observing status before the next send closes that gap. + const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + if (status?.kind === 'exited') { + return await respondToSessionExit( + ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config, + ) + } let operation let result try { @@ -328,16 +367,9 @@ async function executeCommand( if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { - const snapshot = retainedScrollback(ctx, owner, id, latest) - await shells.reset(owner, 'persistent bash shell exited') - return [ - renderShellExitStatus( - renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars), - result.sessionStatus.exitCode, - result.sessionStatus.signal, - ), - SHELL_RESET_MESSAGE, - ].filter(part => part.length > 0).join('\n') + return await respondToSessionExit( + ctx, shells, owner, id, result.sessionStatus, marker, fallback, fallbackTruncated, config, + ) } if (promptCompleted(result)) { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index d43fd64399..789dbaf831 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -98,6 +98,7 @@ type StubMode = | 'incremental-fallback' | 'empty-page-after-latest' | 'paged-scrollback' + | 'exit-after-send' class StubPtySession implements PtyBackendSession { readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' @@ -109,6 +110,7 @@ class StubPtySession implements PtyBackendSession { sends = 0 pendingText = '' historyTruncated = false + throwOnSend = false constructor(mode: StubMode) { this.mode = mode @@ -127,6 +129,7 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.throwOnSend) throw new Error('PTY session has exited') if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { @@ -171,6 +174,18 @@ class StubPtySession implements PtyBackendSession { const incremental = `${start ?? ''}\nincrement\n${this.motd}` return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) } + if (this.mode === 'exit-after-send') { + // A fast `exit` settles the send while the exit event is still in + // flight; the shell flips to exited before the tool's next poll, + // exactly like the real backend. The tool must re-observe status + // instead of sending. + const output = `${start ?? ''}\n` + this.scrollback += output + const settled = this.result(output, 'inferred_idle') + this.statusValue = { kind: 'exited', exitCode: 9, signal: null } + this.throwOnSend = true + return this.operation(Promise.resolve(settled)) + } if (this.mode === 'torn-status') { const output = `${start ?? ''}\nhello from stub\n${end ?? ''}` this.scrollback += output @@ -397,6 +412,21 @@ describe('tool-bash-persistent', () => { expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]') }) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'exit-after-send' + + const result = text(await call(ctx, owner, 'exit')) + expect(result).toContain('[shell exited: code 9]') + expect(result).toContain('next bash call starts from the workspace') + expect(session.closed).toContain('persistent bash shell exited') + + expect(text(await call(ctx, owner, 'echo "$PWD"'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + it('reports a shell exit when the backend has no code or signal', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }) await call(ctx, owner, 'warm up') diff --git a/packages/pty/tool-pwsh-persistent/src/index.ts b/packages/pty/tool-pwsh-persistent/src/index.ts index 53c495585d..e456f47719 100644 --- a/packages/pty/tool-pwsh-persistent/src/index.ts +++ b/packages/pty/tool-pwsh-persistent/src/index.ts @@ -219,6 +219,37 @@ function renderShellExitStatus( return appendStatusMarker(content, marker) } +/** + * Render the exited-session result, reset the owner's shell, and reset the + * message that tells the model the next call starts fresh. + * @param shells - the owner-scoped registry to reset. + * @param status - the exited session status (exit code and signal). + * @returns the complete model-facing result. + */ +async function respondToSessionExit( + ctx: Context, + shells: PersistentShells, + owner: Agent, + id: PtySessionId, + status: { exitCode: number | null; signal: NodeJS.Signals | null }, + marker: CommandMarkers, + wrapped: string, + fallback: string, + fallbackTruncated: boolean, + config: ResolvedConfig, +): Promise { + const snapshot = retainedScrollback(ctx, owner, id) + await shells.reset(owner, 'persistent pwsh shell exited') + return [ + renderShellExitStatus( + renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars), + status.exitCode, + status.signal, + ), + SHELL_RESET_MESSAGE, + ].filter(part => part.length > 0).join('\n') +} + /** * The pwsh prompt function that overrides the backend bootstrap value with * this tool's own prompt. `[char]27`/`[char]7` build the OSC bytes at runtime @@ -317,6 +348,16 @@ async function executeCommand( let fallbackTruncated = false while (true) { + // The shell may flip to exited between iterations (a fast `exit` can + // settle the previous send while its exit event is still in flight, and + // the echoed wrapper can then carry a marker end without status digits); + // re-observing status before the next send closes that gap. + const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + if (status?.kind === 'exited') { + return await respondToSessionExit( + ctx, shells, owner, id, status, marker, wrapped, fallback, fallbackTruncated, config, + ) + } let operation let result try { @@ -359,16 +400,9 @@ async function executeCommand( if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars) } if (result.sessionStatus.kind === 'exited') { - const snapshot = retainedScrollback(ctx, owner, id, latest) - await shells.reset(owner, 'persistent pwsh shell exited') - return [ - renderShellExitStatus( - renderCaptured(partialOutput(snapshot, marker, wrapped, fallback, fallbackTruncated), config.maxOutputChars), - result.sessionStatus.exitCode, - result.sessionStatus.signal, - ), - SHELL_RESET_MESSAGE, - ].filter(part => part.length > 0).join('\n') + return await respondToSessionExit( + ctx, shells, owner, id, result.sessionStatus, marker, wrapped, fallback, fallbackTruncated, config, + ) } if (promptCompleted(result)) { const snapshot = retainedScrollback(ctx, owner, id, latest) diff --git a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts index b1a6ee8395..1f4a480c38 100644 --- a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts @@ -99,6 +99,7 @@ type StubMode = | 'empty-page-after-latest' | 'paged-scrollback' | 'with-echo' + | 'exit-after-send' const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ @@ -113,6 +114,7 @@ class StubPtySession implements PtyBackendSession { sends = 0 pendingText = '' historyTruncated = false + throwOnSend = false constructor(mode: StubMode) { this.mode = mode @@ -131,6 +133,7 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read'))) } if (this.mode === 'send-error') throw new Error('stub send failed') + if (this.throwOnSend) throw new Error('PTY session has exited') if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { @@ -178,6 +181,18 @@ class StubPtySession implements PtyBackendSession { this.scrollback += output return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } + if (this.mode === 'exit-after-send') { + // A fast `exit` settles the send with an echoed wrapper (marker end, + // no status digits) while the exit event is still in flight; the shell + // flips to exited before the tool's next poll, exactly like the real + // ConPTY backend. The tool must re-observe status instead of sending. + const output = `${sent}\n${start ?? ''}\n` + this.scrollback += output + const settled = this.result(output, 'inferred_idle') + this.statusValue = { kind: 'exited', exitCode: 9, signal: null } + this.throwOnSend = true + return this.operation(Promise.resolve(settled)) + } if (this.mode === 'incremental-fallback') { const incremental = `${start ?? ''}\nincrement\n${this.motd}` return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental) @@ -347,6 +362,21 @@ describe('tool-pwsh-persistent', () => { expect(result).not.toContain('Invoke-Expression') }) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + session.mode = 'exit-after-send' + + const result = text(await call(ctx, owner, 'exit')) + expect(result).toContain('[shell exited: code 9]') + expect(result).toContain('next pwsh call starts from the workspace') + expect(session.closed).toContain('persistent pwsh shell exited') + + expect(text(await call(ctx, owner, 'Write-Output "$PWD"'))).toBe('hello from stub') + expect(stub.sessions).toHaveLength(2) + }) + it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => { const { ctx, owner, stub, fiber } = await setup({ backendType: 'stub', From 7c6735c4cb9da6b61d0f20fddabf547ad5dec13d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 00:15:40 +0800 Subject: [PATCH 026/137] docs(catalog): refresh config-catalog source lines after the persistent-shell fixes --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 4 ++-- docs/config-catalog.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 04afdcd0e8..9f44a229d6 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: 8e05f123f9313be19bec3cafe0c73403f26133c6 -config-catalog.zh.md: 8744a33bb622150274826fa5a0f5789454be3345 +config-catalog.md: 8bf9562cb4e91c15e162d50121b53c58decfe183 +config-catalog.zh.md: befbba2d71c3f6c2af512404b6054382c962eaff diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8e05f123f9..8bf9562cb4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2156,7 +2156,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-bash-persistent/src/index.ts:405`](../packages/pty/tool-bash-persistent/src/index.ts) +Source: [`packages/pty/tool-bash-persistent/src/index.ts:437`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2306,7 +2306,7 @@ export interface Config { } ``` -Source: [`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) +Source: [`packages/pty/tool-pwsh-persistent/src/index.ts:470`](../packages/pty/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8744a33bb6..befbba2d71 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2158,7 +2158,7 @@ export interface Config { } ``` -来源:[`packages/pty/tool-bash-persistent/src/index.ts:405`](../packages/pty/tool-bash-persistent/src/index.ts) +来源:[`packages/pty/tool-bash-persistent/src/index.ts:437`](../packages/pty/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2307,7 +2307,7 @@ export interface Config { } ``` -来源:[`packages/pty/tool-pwsh-persistent/src/index.ts:436`](../packages/pty/tool-pwsh-persistent/src/index.ts) +来源:[`packages/pty/tool-pwsh-persistent/src/index.ts:470`](../packages/pty/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` From b1daf0eeaf596e91eab70603fea12f45105999dd Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 01:15:03 +0800 Subject: [PATCH 027/137] test(tools): expect both pwsh tool packages in the harvested catalog roster --- packages/core/tools/tests/gen-tool-catalog.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 8e1e6ad5ef..541bc39d58 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', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', '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', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', '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) { From d6010c2d88c26fc60788e0cee0f2c2c9d518f4fb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 09:52:56 +0800 Subject: [PATCH 028/137] refactor(pty): follow the naming-contract renames across the persistent pwsh stack --- .../2026-08-11-pwsh-persistent-pty.md | 16 +- .../2026-08-11-pwsh-persistent-pty.zh.md | 16 +- apps/cli/tests/windows-shell.spec.ts | 10 +- packages/e2b/e2b/tests/composition.e2e.ts | 2 +- .../shell/tool-bash-persistent/src/index.ts | 4 +- .../tool-pwsh-persistent/README.i18n.yaml | 6 +- .../tool-pwsh-persistent/README.md | 8 +- .../tool-pwsh-persistent/README.zh.md | 6 +- .../tool-pwsh-persistent/package.json | 8 +- .../tool-pwsh-persistent/src/index.ts | 42 +- .../tool-pwsh-persistent/src/invariant.ts | 0 .../tests/loader-composition.spec.ts | 12 +- .../tool-pwsh-persistent/tests/tools.spec.ts | 56 +- .../tool-pwsh-persistent/tsconfig.json | 4 +- .../terminal-bash/tests/local.spec.ts | 14 +- packages/terminal/terminal-bash/tsconfig.json | 2 +- .../tests/workflow-worker-thread.spec.ts | 37 +- pnpm-lock.yaml | 2644 +++++++++-------- scripts/gen-tool-catalog.ts | 6 +- 19 files changed, 1491 insertions(+), 1402 deletions(-) rename packages/{pty => shell}/tool-pwsh-persistent/README.i18n.yaml (54%) rename packages/{pty => shell}/tool-pwsh-persistent/README.md (82%) rename packages/{pty => shell}/tool-pwsh-persistent/README.zh.md (88%) rename packages/{pty => shell}/tool-pwsh-persistent/package.json (90%) rename packages/{pty => shell}/tool-pwsh-persistent/src/index.ts (92%) rename packages/{pty => shell}/tool-pwsh-persistent/src/invariant.ts (100%) rename packages/{pty => shell}/tool-pwsh-persistent/tests/loader-composition.spec.ts (95%) rename packages/{pty => shell}/tool-pwsh-persistent/tests/tools.spec.ts (94%) rename packages/{pty => shell}/tool-pwsh-persistent/tsconfig.json (78%) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 7d4fe5e21f..092302ec00 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -1,4 +1,4 @@ -# Agent Note: Persistent pwsh over the PTY seam on Windows +# Agent Note: Persistent pwsh over the terminal seam on Windows Status: implemented @@ -6,15 +6,15 @@ English | [中文](2026-08-11-pwsh-persistent-pty.zh.md) ## Problem -The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-pty-local` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. +The harness had no persistent shell on Windows. The persistent `bash` stack was POSIX-only by construction: `@deepseek-ai/dsh-subprocess-local` threw at terminal allocation (`createProcessInspector()` rejected win32), `@deepseek-ai/dsh-terminal-bash` was bash-shaped (`/bin/bash` defaults, `PS1`/`PROMPT_COMMAND` environment markers), `@deepseek-ai/dsh-tool-bash-persistent` wrapped commands in bash syntax, and every pty test skipped on win32. The one-shot `pwsh` tool (`@deepseek-ai/dsh-tool-pwsh` over `@deepseek-ai/dsh-pwsh-local`) already ran on Windows, but each call started a fresh `pwsh -Command` process: cwd, `$env:` variables, functions, and interactive children ended with the call, and its README recorded "No persistent shell or PTY" as deferred work. The gap excluded Windows workflows whose state lives in a terminal: stepping a debugger, exploring in a Python or Node REPL, or returning to a shell after interrupting its foreground command — the same class of work the persistent bash pty serves on POSIX. -Two foundations already existed. The PTY service itself (`ctx.pty` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. +Two foundations already existed. the terminal service itself (`ctx.terminals` registry, owner scoping, send/read/signal/kill contract) is platform-neutral. The Loader's `disabled: !!js` interpolation (PR #2234) gates shell rows per platform and pins the invariant that exactly one shell stack mounts per host; a persistent pwsh stack composes through the same rows. ## Decision -A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `pty-local`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. +A model-facing persistent `pwsh` tool ships on Windows with the same contract as `tool-bash-persistent`: one owner-scoped persistent shell per Agent, marker-detected command completion, exact native exit codes, bounded output, and timeout/cancel/`exit` semantics that reset the shell and tell the model. Three pieces deliver it: a Windows substrate in `subprocess-local`, a shell-dialect option in `terminal-bash`, and the new `tool-pwsh-persistent` package with the minimal-preset composition rows. ### Windows substrate in `@deepseek-ai/dsh-subprocess-local` @@ -22,7 +22,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as `LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. -### Shell dialect in `@deepseek-ai/dsh-pty-local` +### Shell dialect in `@deepseek-ai/dsh-terminal-bash` One backend, two dialects: `shellDialect: 'bash' | 'pwsh'` (default `'bash'`, existing deployments byte-identical). The effective `shellPath`/`shellArgs` resolve per dialect (bash `/bin/bash --noprofile --norc -i`; pwsh through the shared `dsh-pwsh-local` resolver with `-NoLogo -NoProfile`, keeping the interactive host for child REPLs). The child environment drops the bash-only `PS1`/`PROMPT_COMMAND` markers and adds `NO_COLOR` for pwsh. pwsh cannot install its prompt from the environment, so the backend writes the prompt function through the session at startup and waits until the controlled prompt is actually visible, looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound; a `session_exit` or `timeout` wait rejects the spawn. Both dialects emit the same BEL-terminated OSC `133;D;` marker, so the sanitizer, `PROMPT_MARKER_PREFIX`, `CONTROLLED_PROMPT`, and the exact-tail readiness logic are reused untouched — the marker stays a readiness signal with an unconsumed payload, exactly as in the bash path, and no model-notification channel was added (aligned with the current implementation; the deferred BEL event channel stays deferred). @@ -34,11 +34,11 @@ Commands run through a wrapper that resets `$LASTEXITCODE` (assignable, verified ### Composition -The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`pty-local` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`pty-local` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. +The minimal preset gates its persistent shell stack by platform with the #2234 `disabled: !!js` interpolation: the bash rows (`terminal-bash` + `tool-bash-persistent`) mount on POSIX, and the pwsh rows (`terminal-bash` with `shellDialect: pwsh` + `tool-pwsh-persistent`) mount on win32 — exactly one persistent shell per host. `windows-shell.spec` pins the per-platform roster; the real Loader composition exercises the whole stack over a real ConPTY pwsh. ### Testing -The Windows test surface follows master's exemption structure: pty-local and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. +The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. ## Alternatives considered @@ -54,7 +54,7 @@ The Windows test surface follows master's exemption structure: pty-local and sub **Windows became a first-class persistent-shell host.** The persistent pwsh stack runs and is coverage-gated on the windows-native lane; the one-shot/persistent shell split mirrors POSIX, and the preset spec pins exactly one shell stack per host on both platforms. -**Windows coverage keeps master's exemption structure.** subprocess-local and pty-local sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. +**Windows coverage keeps master's exemption structure.** subprocess-local and terminal-bash sources stay coverage-exempt and their suites test-excluded on win32 exactly as on master; the Windows code paths are exercised through the win32 dev lane and the real-pwsh tool suites, and the new surface's coverage obligation on the windows-native lane sits on `tool-pwsh-persistent`. **Windows readiness is weaker than Linux.** The pseudo-pgid marker fast path covers shell prompts, but a child without a prompt settles on the silence tier (~3 s), exactly like macOS; there is no exact stdin-wait tier. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index b1bb90218e..857f78c66f 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Windows 上基于 PTY seam 的持久化 pwsh +# Agent Note: Windows 上基于 terminal seam 的持久化 pwsh Status: implemented @@ -6,15 +6,15 @@ Status: implemented ## 问题 -harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-pty-local` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 +harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POSIX-only:`@deepseek-ai/dsh-subprocess-local` 在终端分配时直接抛错(`createProcessInspector()` 拒绝 win32),`@deepseek-ai/dsh-terminal-bash` 是 bash 形态(`/bin/bash` 默认值、`PS1`/`PROMPT_COMMAND` 环境标记),`@deepseek-ai/dsh-tool-bash-persistent` 用 bash 语法包装命令,pty 测试全部在 win32 上 skip。一次性 `pwsh` 工具(`@deepseek-ai/dsh-tool-pwsh` + `@deepseek-ai/dsh-pwsh-local`)已经能在 Windows 运行,但每次调用都是全新的 `pwsh -Command` 进程:cwd、`$env:` 变量、函数和交互式子进程都随调用结束,其 README 把 "No persistent shell or PTY" 记为 deferred work。 这个缺口排除了状态驻留在终端里的 Windows 工作流:单步调试、在 Python 或 Node REPL 中探索、中断前台命令后回到原 shell —— 正是持久 bash pty 在 POSIX 上服务的同一类工作。 -两个基础已经存在。PTY 服务本身(`ctx.pty` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 +两个基础已经存在。PTY 服务本身(`ctx.terminals` 注册表、owner 作用域、send/read/signal/kill 契约)是平台无关的。Loader 的 `disabled: !!js` 插值(PR #2234)按平台门控 shell 行,并钉死了"每宿主恰好挂载一个 shell 栈"的不变量;持久 pwsh 栈通过同一行机制组合。 ## 决定 -模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`pty-local` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 +模型侧持久 `pwsh` 工具在 Windows 上交付,契约与 `tool-bash-persistent` 逐项对齐:每个 Agent 一个 owner 作用域的持久 shell、标记检测的命令完成、精确的原生退出码、有界输出,以及超时/取消/`exit` 时重置 shell 并告知模型的语义。三块交付:`subprocess-local` 的 Windows 基座、`terminal-bash` 的 shell 方言选项、新的 `tool-pwsh-persistent` 包加 minimal 预设组合行。 ### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 @@ -22,7 +22,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS `LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 -### `@deepseek-ai/dsh-pty-local` 的 shell 方言 +### `@deepseek-ai/dsh-terminal-bash` 的 shell 方言 一个 backend、两种方言:`shellDialect: 'bash' | 'pwsh'`(默认 `'bash'`,存量部署逐字节不变)。有效 `shellPath`/`shellArgs` 按方言解析(bash `/bin/bash --noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器取 `-NoLogo -NoProfile`,保留交互宿主供子 REPL)。子环境去掉 bash 专属 `PS1`/`PROMPT_COMMAND` 标记并为 pwsh 加 `NO_COLOR`。pwsh 无法从环境安装提示符,因此 backend 在启动时通过会话写入 prompt 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;`session_exit` 或 `timeout` 结算拒绝 spawn。两种方言发出相同的 BEL 终结 OSC `133;D;` 标记,因此 sanitizer、`PROMPT_MARKER_PREFIX`、`CONTROLLED_PROMPT` 与精确尾部就绪逻辑原样复用——标记仍只是就绪信号、载荷不被消费,与 bash 路径完全一致,且没有新增模型通知通道(与当前实现对齐;延后的 BEL 事件通道保持延后)。 @@ -34,11 +34,11 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS ### 组合 -minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`pty-local` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `pty-local` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 +minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell 栈:bash 行(`terminal-bash` + `tool-bash-persistent`)在 POSIX 挂载,pwsh 行(`shellDialect: pwsh` 的 `terminal-bash` + `tool-pwsh-persistent`)在 win32 挂载——每宿主恰好一个持久 shell。`windows-shell.spec` 钉死按平台的花名册;真实 Loader 组合在真实 ConPTY pwsh 上跑通整条栈。 ### 测试 -Windows 测试面沿用 master 的豁免结构:pty-local 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 +Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 ## 备选方案 @@ -54,7 +54,7 @@ Windows 测试面沿用 master 的豁免结构:pty-local 与 subprocess-local **Windows 成为一等公民的持久 shell 宿主。** 持久 pwsh 栈在 windows-native 车道上运行并受覆盖门禁约束;一次性/持久 shell 的划分与 POSIX 镜像,预设 spec 在两种平台上都钉死每宿主恰好一个 shell 栈。 -**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 pty-local 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 +**Windows 覆盖沿用 master 的豁免结构。** subprocess-local 与 terminal-bash 源码在 win32 上保持覆盖豁免、其套件保持测试排除,与 master 完全一致;Windows 代码路径经 win32 开发车道与真实 pwsh 工具套件验证,新表面的覆盖义务在 windows-native 车道上落在 `tool-pwsh-persistent`。 **Windows 就绪弱于 Linux。** 伪 pgid marker 快路径覆盖 shell 提示符,但没有提示符的子进程按静默档结算(约 3s),与 macOS 完全一致;没有精确的 stdin-wait 档。 diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index b4202c0dc3..ce37022cdf 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -142,17 +142,17 @@ describe('shipped agent presets gate both shell tools by platform', () => { const byId = new Map(rows .filter((entry): entry is Record => typeof entry === 'object' && entry !== null) .map(entry => [entry.id, entry])) - // The bash stack (pty-local + persistent-bash) mounts on POSIX only; the - // pwsh twin (pty-local with shellDialect pwsh + persistent-pwsh) mounts on + // The bash stack (terminal-bash + persistent-bash) mounts on POSIX only; the + // pwsh twin (terminal-bash with shellDialect pwsh + persistent-pwsh) mounts on // win32 only — exactly one persistent shell per host. - for (const id of ['pty-local', 'persistent-bash']) { + for (const id of ['terminal-bash', 'persistent-bash']) { expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(true) expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(false) } - for (const id of ['pty-pwsh', 'persistent-pwsh']) { + for (const id of ['terminal-pwsh', 'persistent-pwsh']) { expect(disabledOn(byId.get(id)!, 'win32'), `${id} on win32`).toBe(false) expect(disabledOn(byId.get(id)!, 'linux'), `${id} on linux`).toBe(true) } - expect(byId.get('pty-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' }) + expect(byId.get('terminal-pwsh')?.config).toMatchObject({ shellDialect: 'pwsh' }) }) }) diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 952da5320d..b01e2fb7b8 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -97,7 +97,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => { whenIdle: () => Promise.resolve(), } const backend = new BashTerminalBackend(ctx, { - backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], + backendType: 'shell', shellDialect: 'bash', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'], rows: 24, cols: 80, scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384, pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000, diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 81c17d9669..05fccf8596 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -208,7 +208,7 @@ async function respondToSessionExit( ctx: Context, shells: PersistentShells, owner: Agent, - id: PtySessionId, + id: TerminalSessionId, status: { exitCode: number | null; signal: NodeJS.Signals | null }, marker: CommandMarkers, fallback: string, @@ -319,7 +319,7 @@ async function executeCommand( // The shell may flip to exited between iterations (a fast `exit` can // settle the previous send while its exit event is still in flight); // re-observing status before the next send closes that gap. - const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status if (status?.kind === 'exited') { return await respondToSessionExit( ctx, shells, owner, id, status, marker, fallback, fallbackTruncated, config, diff --git a/packages/pty/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml similarity index 54% rename from packages/pty/tool-pwsh-persistent/README.i18n.yaml rename to packages/shell/tool-pwsh-persistent/README.i18n.yaml index 3335ee04fc..0786399570 100644 --- a/packages/pty/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/README.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 packages/pty/tool-pwsh-persistent/README.md -README.md: 32fcaf15d38648c347e6423c9b68753ccca91287 -README.zh.md: 8c2201657796d2d6bd39ea5d130ee246a58e17d9 +# pnpm run verify-translation-pairing --write packages/shell/tool-pwsh-persistent/README.md +README.md: 7bb66477ab7ffe52039b0c699d48c9ca761ac04c +README.zh.md: b20041b1d42908d4d1e893455d825eafa2e2f87d diff --git a/packages/pty/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md similarity index 82% rename from packages/pty/tool-pwsh-persistent/README.md rename to packages/shell/tool-pwsh-persistent/README.md index 32fcaf15d3..7bb66477ab 100644 --- a/packages/pty/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -Model-facing `pwsh(command)` backed by one owner-scoped `ctx.pty` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend (a `pty-local` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect. +Model-facing `pwsh(command)` backed by one owner-scoped `ctx.terminals` shell. The package owns the tool contract and shell reuse; deployments select the terminal backend (a `terminal-bash` instance configured with `shellDialect: pwsh`) and sandbox policy. It is the Windows counterpart of `tool-bash-persistent`: same persistent-state contract, PowerShell dialect. ## Config | Key | Default | Meaning | |---|---:|---| -| `backendType` | `shell` | Registered PTY backend used for each Agent shell. | +| `backendType` | `shell` | Registered terminal backend used for each Agent shell. | | `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. | | `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. | | `description` | Persistent-shell description | Model-facing environment contract. | @@ -33,7 +33,7 @@ Prefix-stable while the configured description and schema remain unchanged. #### What the model sees -Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the PTY has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. +Commands share one shell per Agent, so cwd, `$env:` variables, functions, and background jobs persist across calls. Results exclude private completion markers, the shell prompt, and the echoed input line (PSReadLine renders submitted input back into the stream; the marker-anchored extraction and the wrapper-source strip remove it). A nonzero wrapped command appends `[exit code: N]` — the exact native exit code when the command ran a native program, `1` for a terminating PowerShell error. A shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither (Windows forced termination reports exit 1 without a signal), then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice; if the terminal has already dropped that prefix, the result says so explicitly. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset. #### Token effect @@ -45,7 +45,7 @@ Append-only tool results follow the reusable request prefix. ## Known Limitations and Deferred Work -- The tool requires an owning Agent and a real PTY backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh). +- The tool requires an owning Agent and a real terminal backend with a pwsh dialect (Windows ConPTY or a POSIX pwsh). - **Input echo is unavoidable**: PowerShell's PSReadLine renders submitted input back into the terminal stream, and there is no `stty -echo` equivalent. The marker-anchored extraction excludes the echo in complete results; the wrapper-source strip covers fallback paths, but a wrapper that wraps across the terminal width may leave a partial echo in partial-output results, bounded by `maxOutputChars`. - Raw ESC characters inside model commands are unsupported: PSReadLine consumes them before execution. The wrapper escapes the control bytes it needs (`[char]27`-built OSC markers, backtick escapes for the body). - A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path. diff --git a/packages/pty/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md similarity index 88% rename from packages/pty/tool-pwsh-persistent/README.zh.md rename to packages/shell/tool-pwsh-persistent/README.zh.md index 8c22016577..b20041b1d4 100644 --- a/packages/pty/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) | 中文 -模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.pty` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 PTY backend(配置 `shellDialect: pwsh` 的 `pty-local` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。 +模型侧 `pwsh(command)`,由一个 owner 作用域的 `ctx.terminals` shell 支撑。本包拥有工具契约与 shell 复用;部署方选择 terminal backend(配置 `shellDialect: pwsh` 的 `terminal-bash` 实例)与沙箱策略。它是 `tool-bash-persistent` 的 Windows 对应物:相同的持久状态契约,PowerShell 方言。 ## 配置 | 键 | 默认值 | 含义 | |---|---:|---| -| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY backend。 | +| `backendType` | `shell` | 每个 Agent shell 使用的已注册 terminal backend。 | | `timeoutMs` | `300000` | 单条命令的墙钟上限;超时关闭 shell。 | | `maxOutputChars` | `16000` | 保留的命令输出字符上限;固定诊断文本在其后追加。 | | `description` | 持久 shell 描述 | 模型可见的环境契约。 | @@ -45,7 +45,7 @@ ## 已知限制与延后工作 -- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 PTY backend(Windows ConPTY 或 POSIX 上的 pwsh)。 +- 工具需要拥有 Agent 与一个真实支持 pwsh 方言的 terminal backend(Windows ConPTY 或 POSIX 上的 pwsh)。 - **输入回显不可避免**:PowerShell 的 PSReadLine 会把提交的输入渲染回终端流,且没有 `stty -echo` 的对应物。完整结果中 marker 锚定提取排除回显;包装器原文剥离覆盖回退路径,但跨越终端宽度的包装器折行可能在部分输出结果中残留片段回显,受 `maxOutputChars` 约束。 - 模型命令中的裸 ESC 字符不受支持:PSReadLine 会在执行前吞掉它们。包装器转义它需要的控制字节(`[char]27` 构造的 OSC 标记、body 的反引号转义)。 - 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。 diff --git a/packages/pty/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json similarity index 90% rename from packages/pty/tool-pwsh-persistent/package.json rename to packages/shell/tool-pwsh-persistent/package.json index 1cf2e224d0..5875376802 100644 --- a/packages/pty/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/pty/tool-pwsh-persistent" + "directory": "packages/shell/tool-pwsh-persistent" }, "type": "module", "main": "lib/index.js", @@ -33,7 +33,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/cordis": "workspace:^" @@ -47,8 +47,8 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-pty": "workspace:^", - "@deepseek-ai/dsh-pty-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/pty/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts similarity index 92% rename from packages/pty/tool-pwsh-persistent/src/index.ts rename to packages/shell/tool-pwsh-persistent/src/index.ts index e456f47719..cce7ddd001 100644 --- a/packages/pty/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty' +import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -48,7 +48,7 @@ interface CapturedOutput { } interface PersistentShells { - get(owner: Agent, signal: AbortSignal): Promise + get(owner: Agent, signal: AbortSignal): Promise reset(owner: Agent, reason: string): Promise } @@ -128,7 +128,7 @@ function commandOutput( } } -function promptCompleted(result: PtySendResult): boolean { +function promptCompleted(result: TerminalSendResult): boolean { return result.viewport.endsWith(SHELL_PROMPT) || result.viewport.endsWith(`${SHELL_PROMPT}\r\n`) || result.viewport.endsWith(`${SHELL_PROMPT}\n`) @@ -164,7 +164,7 @@ async function pause(): Promise { await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)) } -function nextScrollbackOffset(page: PtyReadResult, offset: number): number | undefined { +function nextScrollbackOffset(page: TerminalReadResult, offset: number): number | undefined { if (page.text.length === 0 || page.lineEnd <= offset) return undefined return page.lineEnd } @@ -172,15 +172,15 @@ function nextScrollbackOffset(page: PtyReadResult, offset: number): number | und function retainedScrollback( ctx: Context, owner: Agent, - id: PtySessionId, - latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), + id: TerminalSessionId, + latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }), ): RetainedOutput { const pages: string[] = latest.text.length === 0 ? [] : [latest.text] let offset = latest.lineEnd let truncated = latest.truncated while (true) { if (offset >= latest.totalLines) break - const page = ctx.pty.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) + const page = ctx.terminals.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES }) truncated ||= page.truncated if (page.text.length > 0) pages.unshift(page.text) const next = nextScrollbackOffset(page, offset) @@ -230,7 +230,7 @@ async function respondToSessionExit( ctx: Context, shells: PersistentShells, owner: Agent, - id: PtySessionId, + id: TerminalSessionId, status: { exitCode: number | null; signal: NodeJS.Signals | null }, marker: CommandMarkers, wrapped: string, @@ -260,15 +260,15 @@ const PWSH_PROMPT_SETUP = "function prompt { [Console]::Write([char]27 + ']133;D;' + [int]$LASTEXITCODE + [char]7); '" + SHELL_PROMPT + "' }" function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells { - const pending = new WeakMap>() - const live = new Map() - const creating = new Set>() + const pending = new WeakMap>() + const live = new Map() + const creating = new Set>() const ownerCleanupInstalled = new WeakSet() const lifecycle = new AbortController() - const close = async (owner: Agent, id: PtySessionId, reason: string): Promise => { - if (!ctx.pty.list(owner).some(snapshot => snapshot.sessionId === id)) return - await ctx.pty.kill(owner, id, reason) + const close = async (owner: Agent, id: TerminalSessionId, reason: string): Promise => { + if (!ctx.terminals.list(owner).some(snapshot => snapshot.sessionId === id)) return + await ctx.terminals.kill(owner, id, reason) } ctx.effect(() => async () => { @@ -286,14 +286,14 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell if (id !== undefined) await close(owner, id, reason) } - const get = (owner: Agent, signal: AbortSignal): Promise => { + const get = (owner: Agent, signal: AbortSignal): Promise => { const existing = pending.get(owner) if (existing !== undefined) return existing const combinedSignal = AbortSignal.any([signal, lifecycle.signal]) const creation = (async () => { try { const cwd = owner.session.header.cwd - const spawned = await ctx.pty.spawn(owner, { + const spawned = await ctx.terminals.spawn(owner, { type: config.backendType, ...cwd === undefined ? {} : { cwd }, }, combinedSignal) @@ -305,7 +305,7 @@ function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShell live.delete(owner) }, 'tool-pwsh-persistent owner cache cleanup') } - const setup = ctx.pty.startSend(owner, spawned.sessionId, { + const setup = ctx.terminals.startSend(owner, spawned.sessionId, { text: PWSH_PROMPT_SETUP, submit: true, signal: combinedSignal, @@ -352,7 +352,7 @@ async function executeCommand( // settle the previous send while its exit event is still in flight, and // the echoed wrapper can then carry a marker end without status digits); // re-observing status before the next send closes that gap. - const status = ctx.pty.list(owner).find(session => session.sessionId === id)?.status + const status = ctx.terminals.list(owner).find(session => session.sessionId === id)?.status if (status?.kind === 'exited') { return await respondToSessionExit( ctx, shells, owner, id, status, marker, wrapped, fallback, fallbackTruncated, config, @@ -361,7 +361,7 @@ async function executeCommand( let operation let result try { - operation = ctx.pty.startSend(owner, id, { + operation = ctx.terminals.startSend(owner, id, { text: first ? wrapped : '', submit: first, signal: commandDeadline.signal, @@ -375,7 +375,7 @@ async function executeCommand( const incremental = operation.readOutput() fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport fallbackTruncated ||= incremental.truncated || result.truncated - const latest = ctx.pty.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) + const latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }) const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE) if (timedOut !== undefined) { const snapshot = retainedScrollback(ctx, owner, id, latest) @@ -464,7 +464,7 @@ function registerPersistentPwsh(ctx: Context, config: ResolvedConfig): void { } export const name = 'tool-pwsh-persistent' -export const inject = ['tools', 'pty'] +export const inject = ['tools', 'terminals'] /** Configuration for the persistent pwsh tool. */ export interface Config { diff --git a/packages/pty/tool-pwsh-persistent/src/invariant.ts b/packages/shell/tool-pwsh-persistent/src/invariant.ts similarity index 100% rename from packages/pty/tool-pwsh-persistent/src/invariant.ts rename to packages/shell/tool-pwsh-persistent/src/invariant.ts diff --git a/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts similarity index 95% rename from packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts rename to packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index fc10f9cfe3..1a95d7fe23 100644 --- a/packages/pty/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -11,8 +11,8 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import PtyService from '@deepseek-ai/dsh-pty' -import * as PtyLocal from '@deepseek-ai/dsh-pty-local' +import TerminalSessionService from '@deepseek-ai/dsh-terminal' +import * as TerminalBash from '@deepseek-ai/dsh-terminal-bash' import SandboxProvider from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -78,14 +78,14 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", - "- name: '@deepseek-ai/dsh-pty'", + "- name: '@deepseek-ai/dsh-terminal'", "- name: '@deepseek-ai/dsh-test-sandbox'", "- name: '@deepseek-ai/dsh-sandbox-policy'", ' config:', ' mode: danger-full-access', ` workspaceRoot: ${JSON.stringify(root)}`, "- name: '@deepseek-ai/dsh-subprocess-local'", - "- name: '@deepseek-ai/dsh-pty-local'", + "- name: '@deepseek-ai/dsh-terminal-bash'", ' config:', ' shellDialect: pwsh', ' pollIntervalMs: 10', @@ -109,11 +109,11 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRegistry], - ['@deepseek-ai/dsh-pty', PtyService], + ['@deepseek-ai/dsh-terminal', TerminalSessionService], ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], ['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService], ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessService], - ['@deepseek-ai/dsh-pty-local', PtyLocal], + ['@deepseek-ai/dsh-terminal-bash', TerminalBash], ['@deepseek-ai/dsh-tool-pwsh-persistent', ToolPwshPersistent], ]) context.loader.internal = { diff --git a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts similarity index 94% rename from packages/pty/tool-pwsh-persistent/tests/tools.spec.ts rename to packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index 1f4a480c38..6a856b1ff8 100644 --- a/packages/pty/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -4,17 +4,17 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import PtyService from '@deepseek-ai/dsh-pty' +import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { - PtyBackend, - PtyBackendSession, - PtyReadRequest, - PtySendOperation, - PtySendRequest, - PtySessionStatus, - PtySignal, - PtyWaitReason, -} from '@deepseek-ai/dsh-pty' + TerminalBackend, + TerminalBackendSession, + TerminalReadRequest, + TerminalSendOperation, + TerminalSendRequest, + TerminalSessionStatus, + TerminalSignal, + TerminalWaitReason, +} from '@deepseek-ai/dsh-terminal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' @@ -104,10 +104,10 @@ type StubMode = const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ -class StubPtySession implements PtyBackendSession { +class StubTerminalSession implements TerminalBackendSession { readonly motd = '__DSH_PERSISTENT_PWSH_PROMPT__ ' readonly pid = 123 - statusValue: PtySessionStatus = { kind: 'running' } + statusValue: TerminalSessionStatus = { kind: 'running' } scrollback = this.motd closed: string[] = [] mode: StubMode @@ -120,7 +120,7 @@ class StubPtySession implements PtyBackendSession { this.mode = mode } - startSend(request: PtySendRequest): PtySendOperation { + startSend(request: TerminalSendRequest): TerminalSendOperation { this.sends += 1 if (request.text.startsWith('function prompt')) { if (this.mode === 'init-exit') { @@ -135,7 +135,7 @@ class StubPtySession implements PtyBackendSession { if (this.mode === 'send-error') throw new Error('stub send failed') if (this.throwOnSend) throw new Error('PTY session has exited') if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') { - const done = new Promise>((resolve) => { + const done = new Promise>((resolve) => { request.signal?.addEventListener('abort', () => { const start = START_PATTERN.exec(request.text)?.[0] const end = END_PATTERN.exec(request.text)?.[0] @@ -232,7 +232,7 @@ class StubPtySession implements PtyBackendSession { return this.operation(Promise.resolve(this.result(output, 'stdin_read'))) } - read(request: PtyReadRequest) { + read(request: TerminalReadRequest) { if (this.mode === 'empty-read') { return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false } } @@ -265,7 +265,7 @@ class StubPtySession implements PtyBackendSession { } } - signal(_signal: PtySignal) { + signal(_signal: TerminalSignal) { return Promise.resolve({ delivered: true as const, targetPgid: 123 }) } @@ -278,11 +278,11 @@ class StubPtySession implements PtyBackendSession { this.statusValue = { kind: 'exited', exitCode: 0, signal: null } } - private result(viewport: string, waitReason: PtyWaitReason) { + private result(viewport: string, waitReason: TerminalWaitReason) { return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false } } - private operation(done: Promise>, delta = ''): PtySendOperation { + private operation(done: Promise>, delta = ''): TerminalSendOperation { return { done, readOutput: () => ({ delta, truncated: false }), @@ -292,12 +292,12 @@ class StubPtySession implements PtyBackendSession { } function stubBackend(initialMode: StubMode = 'normal') { - const sessions: StubPtySession[] = [] - const backend: PtyBackend = { + const sessions: StubTerminalSession[] = [] + const backend: TerminalBackend = { type: 'stub', async spawn() { if (initialMode === 'spawn-error') throw new Error('stub spawn failed') - const session = new StubPtySession(initialMode) + const session = new StubTerminalSession(initialMode) sessions.push(session) return session }, @@ -314,9 +314,9 @@ async function setup( await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(PtyService) + await ctx.plugin(TerminalSessionService) const stub = stubBackend(initialMode) - ctx.pty.registerBackend(stub.backend) + ctx.terminals.registerBackend(stub.backend) const fiber = await ctx.plugin(ToolPwshPersistent, config) return { ctx, stub, fiber, owner: agent(ctx, '/workspace') } } @@ -433,9 +433,9 @@ describe('tool-pwsh-persistent', () => { await call(ctx, owner, 'another shell') expect(stub.sessions).toHaveLength(3) - const externallyClosed = ctx.pty.list(owner)[0]?.sessionId + const externallyClosed = ctx.terminals.list(owner)[0]?.sessionId expect(externallyClosed).toBeDefined() - await ctx.pty.kill(owner, externallyClosed!, 'external cleanup') + await ctx.terminals.kill(owner, externallyClosed!, 'external cleanup') await fiber.dispose() expect(stub.sessions[2]?.closed).toEqual(['external cleanup']) }) @@ -572,10 +572,10 @@ describe('tool-pwsh-persistent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(PtyService) + await ctx.plugin(TerminalSessionService) const spawnStarted = Promise.withResolvers() const spawnAborted = Promise.withResolvers() - ctx.pty.registerBackend({ + ctx.terminals.registerBackend({ type: 'slow', spawn: spec => new Promise((_resolve, reject) => { spawnStarted.resolve(undefined) @@ -595,7 +595,7 @@ describe('tool-pwsh-persistent', () => { await fiber.dispose() await spawnAborted.promise expect((await running).isError).toBe(true) - expect(ctx.pty.list(owner)).toEqual([]) + expect(ctx.terminals.list(owner)).toEqual([]) }) it('rejects invalid config and invalid calls', async () => { diff --git a/packages/pty/tool-pwsh-persistent/tsconfig.json b/packages/shell/tool-pwsh-persistent/tsconfig.json similarity index 78% rename from packages/pty/tool-pwsh-persistent/tsconfig.json rename to packages/shell/tool-pwsh-persistent/tsconfig.json index 57c13a61c2..42ce584d6a 100644 --- a/packages/pty/tool-pwsh-persistent/tsconfig.json +++ b/packages/shell/tool-pwsh-persistent/tsconfig.json @@ -10,8 +10,8 @@ { "path": "../../../vendor/schemastery" }, { "path": "../../core/agent" }, { "path": "../../core/tools" }, - { "path": "../pty" }, - { "path": "../../support/invariants" }, + { "path": "../../terminal/terminal" }, + { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../util/timeout" } ] } diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 40383a99d1..232ef7d984 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -259,7 +259,7 @@ const hasPwsh = spawnSync( { encoding: 'utf8' }, ).status === 0 -describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { +describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => { it('bootstraps a persistent pwsh, persists state, and scrubs secrets', async () => { const previous = process.env.DSH_TEST_SECRET process.env.DSH_TEST_SECRET = 'must-not-leak' @@ -269,15 +269,15 @@ describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { handoffGraceMs: 300, timeoutMs: 8_000, }, 'pwsh') - const created = await ctx.pty.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root }) expect(created.motd).toContain('dsh> ') - const first = ctx.pty.startSend(agent, created.sessionId, { + const first = ctx.terminals.startSend(agent, created.sessionId, { text: '$env:KEEP = "ok"; Set-Location /', submit: true, }) expect((await first.done).waitReason).toBe('stdin_read') - const second = ctx.pty.startSend(agent, created.sessionId, { + const second = ctx.terminals.startSend(agent, created.sessionId, { text: 'Write-Output "keep=$env:KEEP secret=$env:DSH_TEST_SECRET"', submit: true, }) @@ -286,9 +286,9 @@ describe.skipIf(!hasPwsh)('pty-local pwsh real shell', () => { expect(result.viewport).toContain('secret=') expect(result.viewport).not.toContain('must-not-leak') - expect(ctx.pty.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok') - expect(await ctx.pty.kill(agent, created.sessionId)).toBe(true) - expect(ctx.pty.list(agent)).toEqual([]) + expect(ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 40 }).text).toContain('keep=ok') + expect(await ctx.terminals.kill(agent, created.sessionId)).toBe(true) + expect(ctx.terminals.list(agent)).toEqual([]) } finally { if (previous === undefined) delete process.env.DSH_TEST_SECRET else process.env.DSH_TEST_SECRET = previous diff --git a/packages/terminal/terminal-bash/tsconfig.json b/packages/terminal/terminal-bash/tsconfig.json index 8b8a378cdf..b42e1015be 100644 --- a/packages/terminal/terminal-bash/tsconfig.json +++ b/packages/terminal/terminal-bash/tsconfig.json @@ -18,7 +18,7 @@ "path": "../../../vendor/schemastery" }, { - "path": "../../bash/pwsh-local" + "path": "../../shell/pwsh-local" }, { "path": "../../core/agent" diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts index ced9a632f4..c22888479a 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' import { Context } from '@deepseek-ai/cordis' @@ -9,6 +10,7 @@ import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRu import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRun, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerThreadWorkflowEngine, { type Config } from '../src/index.ts' +import { workerSpawnEnv } from '../src/host.ts' import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts' import { SessionId } from '@deepseek-ai/dsh-session' @@ -559,24 +561,44 @@ describe('dsh-workflow-worker-thread', () => { expect(result.value).toBe('fine') }) - it('the worker spawns with an EMPTY environment: an escaped script finds no ambient credentials', async () => { + it('the worker spawns with a scrubbed environment: an escaped script finds no ambient credentials', async () => { const { ctx, parent } = await setup() // A canary in the HARNESS process's env: with an inherited environment // the escape below would read it back (exactly how DEEPSEEK_API_KEY - // would leak); env: {} in the spawn options is what keeps it out. + // would leak); the worker env keeps every ambient variable out. Windows + // additionally receives the host temp path (TMP/TEMP) so `os.tmpdir()` + // inside the worker resolves instead of degrading to a cwd-relative + // `undefined\temp` (tsx writes its transform cache there). process.env.WORKFLOW_ENV_CANARY = 'leak me' try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).length } + return { canary: proc.env.WORKFLOW_ENV_CANARY ?? null, keys: Object.keys(proc.env).sort() } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ canary: null, keys: 0 }) + const expectedKeys = process.platform === 'win32' ? ['TEMP', 'TMP'] : [] + expect(result.value).toEqual({ canary: null, keys: expectedKeys }) } finally { delete process.env.WORKFLOW_ENV_CANARY } }) + it('workerSpawnEnv injects the host temp path on win32 and leaves the POSIX peer empty', () => { + const tmp = tmpdir() + expect(workerSpawnEnv('win32')).toEqual({ TMP: tmp, TEMP: tmp }) + expect(workerSpawnEnv('linux')).toEqual({}) + }) + + it('workerSpawnEnv forwards TSX_TSCONFIG_PATH when the snapshot harness pins it', () => { + const tsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + expect(workerSpawnEnv('linux', tsconfig)).toEqual({ TSX_TSCONFIG_PATH: tsconfig }) + expect(workerSpawnEnv('win32', tsconfig)).toEqual({ + TMP: tmpdir(), + TEMP: tmpdir(), + TSX_TSCONFIG_PATH: tsconfig, + }) + }) + it('the unbuilt worker forwards exactly TSX_TSCONFIG_PATH through the scrub: the paths-map pin survives, secrets do not', async () => { const { ctx, parent } = await setup() // The ACP snapshot harness runs the parent with its cwd OUTSIDE the @@ -589,10 +611,13 @@ describe('dsh-workflow-worker-thread', () => { try { const result = await run(ctx, parent, scripted(` const proc = ${ESCAPE} - return { keys: Object.keys(proc.env), tsconfig: proc.env.TSX_TSCONFIG_PATH } + return { keys: Object.keys(proc.env).sort(), tsconfig: proc.env.TSX_TSCONFIG_PATH } `)) expect(result.stopReason).toBe('completed') - expect(result.value).toEqual({ keys: ['TSX_TSCONFIG_PATH'], tsconfig }) + const expectedKeys = process.platform === 'win32' + ? ['TEMP', 'TMP', 'TSX_TSCONFIG_PATH'] + : ['TSX_TSCONFIG_PATH'] + expect(result.value).toEqual({ keys: expectedKeys, tsconfig }) } finally { delete process.env.TSX_TSCONFIG_PATH delete process.env.WORKFLOW_ENV_CANARY diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b21718dde1..ecf3116f2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../vendor/timer + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../packages/context/agent-instructions '@deepseek-ai/dsh-agent-tool-presentation': specifier: workspace:^ version: link:../../packages/core/agent-tool-presentation @@ -165,9 +168,6 @@ importers: '@deepseek-ai/dsh-compaction-tool-result-pruner': specifier: workspace:^ version: link:../../packages/compaction/compaction-tool-result-pruner - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../packages/util/launch-environment '@deepseek-ai/dsh-fs-local': specifier: workspace:^ version: link:../../packages/fs/fs-local @@ -180,30 +180,33 @@ importers: '@deepseek-ai/dsh-headless': specifier: workspace:^ version: link:../../packages/bundle/headless - '@deepseek-ai/dsh-mcp-client': - specifier: workspace:^ - version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../packages/util/home-paths + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../packages/jobs/jobs-local + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../packages/util/launch-environment + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-persona': specifier: workspace:^ version: link:../../packages/preset/persona '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../../packages/terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/shell/pwsh-local '@deepseek-ai/dsh-pwsh-sandbox': specifier: workspace:^ version: link:../../packages/shell/pwsh-sandbox + '@deepseek-ai/dsh-schedule': + specifier: workspace:^ + version: link:../../packages/schedule/schedule '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../packages/session/session-projection @@ -216,9 +219,12 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem - '@deepseek-ai/dsh-jobs-local': + '@deepseek-ai/dsh-terminal': specifier: workspace:^ - version: link:../../packages/jobs/jobs-local + version: link:../../packages/terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-time-context': specifier: workspace:^ version: link:../../packages/context/time-context @@ -249,15 +255,18 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:^ + version: link:../../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:^ version: link:../../packages/workflow/tool-ralph - '@deepseek-ai/dsh-schedule': - specifier: workspace:^ - version: link:../../packages/schedule/schedule '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -270,9 +279,6 @@ importers: '@deepseek-ai/dsh-tool-subagent-control': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent-control - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../packages/todo/tool-todo @@ -288,9 +294,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:^ version: link:../../packages/workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../packages/context/agent-instructions commander: specifier: ^15.0.0 version: 15.0.0 @@ -304,12 +307,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent - '@deepseek-ai/dsh-host-frontend-static': - specifier: workspace:^ - version: link:../../packages/host/frontend-static '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-frontend-static': + specifier: workspace:^ + version: link:../../packages/host/frontend-static '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -425,6 +428,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../packages/core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:* + version: link:../packages/context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:* version: link:../packages/core/agent-loop @@ -437,12 +443,6 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:* version: link:../packages/attachment/attachment-local - '@deepseek-ai/dsh-shell': - specifier: workspace:* - version: link:../packages/shell/shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:* - version: link:../packages/shell/shell-env '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/shell/bash-local @@ -503,9 +503,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:* version: link:../packages/runtime-diagnostics/invariants - '@deepseek-ai/dsh-sdk-jsonrpc-server': + '@deepseek-ai/dsh-jobs-local': specifier: workspace:* - version: link:../packages/sdk/server + version: link:../packages/jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../packages/llm/llm @@ -533,12 +533,6 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:* version: link:../packages/plan/plan-mode - '@deepseek-ai/dsh-terminal': - specifier: workspace:* - version: link:../packages/terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:* - version: link:../packages/terminal/terminal-bash '@deepseek-ai/dsh-pwsh-local': specifier: workspace:* version: link:../packages/shell/pwsh-local @@ -557,6 +551,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:* version: link:../packages/core/scope + '@deepseek-ai/dsh-sdk-jsonrpc-server': + specifier: workspace:* + version: link:../packages/sdk/server '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../packages/core/session @@ -590,6 +587,12 @@ importers: '@deepseek-ai/dsh-settings-file': specifier: workspace:* version: link:../packages/settings/settings-file + '@deepseek-ai/dsh-shell': + specifier: workspace:* + version: link:../packages/shell/shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:* + version: link:../packages/shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:* version: link:../packages/skill/skill @@ -632,15 +635,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../packages/core/system-prompt - '@deepseek-ai/dsh-jobs-local': + '@deepseek-ai/dsh-terminal': specifier: workspace:* - version: link:../packages/jobs/jobs-local + version: link:../packages/terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:* + version: link:../packages/terminal/terminal-bash '@deepseek-ai/dsh-time-context': specifier: workspace:* version: link:../packages/context/time-context - '@deepseek-ai/dsh-tool-call-timeout-policy': - specifier: workspace:* - version: link:../packages/guard/timeout-policy '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../packages/llm/token-meter @@ -653,6 +656,9 @@ importers: '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:* version: link:../packages/shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-call-timeout-policy': + specifier: workspace:* + version: link:../packages/guard/timeout-policy '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../packages/extensions/tool-cordis @@ -665,12 +671,12 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:* version: link:../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:* + version: link:../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-lsp': specifier: workspace:* version: link:../packages/lsp/tool-lsp - '@deepseek-ai/dsh-tool-terminal': - specifier: workspace:* - version: link:../packages/terminal/tool-terminal '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:* version: link:../packages/shell/tool-pwsh @@ -695,9 +701,9 @@ importers: '@deepseek-ai/dsh-tool-subagent-report': specifier: workspace:* version: link:../packages/subagent/tool-subagent-report - '@deepseek-ai/dsh-tool-jobs': + '@deepseek-ai/dsh-tool-terminal': specifier: workspace:* - version: link:../packages/jobs/tool-jobs + version: link:../packages/terminal/tool-terminal '@deepseek-ai/dsh-tool-todo': specifier: workspace:* version: link:../packages/todo/tool-todo @@ -725,9 +731,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:* version: link:../packages/workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:* - version: link:../packages/context/agent-instructions native/landlock-run: devDependencies: @@ -898,299 +901,12 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../attachment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths - - packages/shell/shell: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - - packages/shell/shell-env: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths - '@deepseek-ai/dsh-session-persistence': - specifier: workspace:^ - version: link:../../session/session-persistence - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - - packages/shell/bash-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - - packages/shell/bash-sandbox: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../bash-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-local': - specifier: workspace:^ - version: link:../../sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/node-addon-landlock-run': - specifier: workspace:* - version: link:../../../native/landlock-run/packages/entry - - packages/shell/pwsh-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - - packages/shell/pwsh-sandbox: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-pwsh-local': - specifier: workspace:^ - version: link:../pwsh-local - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-local': - specifier: workspace:^ - version: link:../../sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - - packages/shell/tool-bash: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../shell-env - '@deepseek-ai/dsh-bash-local': - specifier: workspace:^ - version: link:../bash-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session/session-persistence-jsonl - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../interaction/user-approval - - packages/shell/tool-pwsh: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../shell-env - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../test-support/loader-smoke - '@deepseek-ai/dsh-pwsh-local': - specifier: workspace:^ - version: link:../pwsh-local - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../interaction/user-approval packages/boot/app-boot: dependencies: @@ -1216,15 +932,15 @@ importers: '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1264,6 +980,9 @@ importers: '@deepseek-ai/dsh-agent-default-model': specifier: workspace:^ version: link:../../core/agent-default-model + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1273,9 +992,6 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:^ version: link:../../attachment/attachment-local - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../shell/shell-env '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ version: link:../../shell/bash-sandbox @@ -1315,6 +1031,9 @@ importers: '@deepseek-ai/dsh-goal-round-driver': specifier: workspace:^ version: link:../../goal/goal-round-driver + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -1372,6 +1091,9 @@ importers: '@deepseek-ai/dsh-settings-file': specifier: workspace:^ version: link:../../settings/settings-file + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill @@ -1408,18 +1130,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-call-timeout-policy': - specifier: workspace:^ - version: link:../../guard/timeout-policy '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../shell/tool-bash + '@deepseek-ai/dsh-tool-call-timeout-policy': + specifier: workspace:^ + version: link:../../guard/timeout-policy '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../fs/tool-fs @@ -1429,6 +1148,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:^ version: link:../../shell/tool-pwsh @@ -1450,9 +1172,6 @@ importers: '@deepseek-ai/dsh-tool-subagent-report': specifier: workspace:^ version: link:../../subagent/tool-subagent-report - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../todo/tool-todo @@ -1486,9 +1205,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:^ version: link:../../workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../context/agent-instructions devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1578,57 +1294,54 @@ importers: '@deepseek-ai/dsh-client-ui-directory-picker-native': specifier: workspace:^ version: link:../../client/ui-directory-picker-native - '@deepseek-ai/dsh-client-ui-message-feedback': - specifier: workspace:^ - version: link:../../client/ui-message-feedback '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../client/ui-goal + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../../client/ui-input-trigger + '@deepseek-ai/dsh-client-ui-jobs': + specifier: workspace:^ + version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../../client/ui-layout + '@deepseek-ai/dsh-client-ui-message-feedback': + specifier: workspace:^ + version: link:../../client/ui-message-feedback '@deepseek-ai/dsh-client-ui-model-selection': specifier: workspace:^ version: link:../../client/ui-model-selection - '@deepseek-ai/dsh-client-ui-settings-models': - specifier: workspace:^ - version: link:../../client/ui-settings-models '@deepseek-ai/dsh-client-ui-permission-presets': specifier: workspace:^ version: link:../../client/ui-permission-presets '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../client/ui-plan - '@deepseek-ai/dsh-client-ui-settings-plugins': - specifier: workspace:^ - version: link:../../client/ui-settings-plugins - '@deepseek-ai/dsh-client-ui-settings-plugin-inventory': - specifier: workspace:^ - version: link:../../client/ui-settings-plugin-inventory - '@deepseek-ai/dsh-client-ui-user-questions': - specifier: workspace:^ - version: link:../../client/ui-user-questions '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../../client/ui-settings '@deepseek-ai/dsh-client-ui-settings-general': specifier: workspace:^ version: link:../../client/ui-settings-general + '@deepseek-ai/dsh-client-ui-settings-models': + specifier: workspace:^ + version: link:../../client/ui-settings-models + '@deepseek-ai/dsh-client-ui-settings-plugin-inventory': + specifier: workspace:^ + version: link:../../client/ui-settings-plugin-inventory + '@deepseek-ai/dsh-client-ui-settings-plugins': + specifier: workspace:^ + version: link:../../client/ui-settings-plugins '@deepseek-ai/dsh-client-ui-sidebar': specifier: workspace:^ version: link:../../client/ui-sidebar '@deepseek-ai/dsh-client-ui-skill': specifier: workspace:^ version: link:../../client/ui-skill - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../../client/ui-input-trigger '@deepseek-ai/dsh-client-ui-subagent': specifier: workspace:^ version: link:../../client/ui-subagent - '@deepseek-ai/dsh-client-ui-jobs': - specifier: workspace:^ - version: link:../../client/ui-jobs '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme @@ -1638,6 +1351,9 @@ importers: '@deepseek-ai/dsh-client-ui-trajectory': specifier: workspace:^ version: link:../../client/ui-trajectory + '@deepseek-ai/dsh-client-ui-user-questions': + specifier: workspace:^ + version: link:../../client/ui-user-questions '@deepseek-ai/dsh-client-ui-workflow-run': specifier: workspace:^ version: link:../../client/ui-workflow-run @@ -1650,12 +1366,6 @@ importers: '@deepseek-ai/dsh-code-runtime-worker-thread': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker-thread - '@deepseek-ai/dsh-web-frontend': - specifier: workspace:^ - version: link:../../../apps/web - '@deepseek-ai/dsh-host-frontend-static': - specifier: workspace:^ - version: link:../../host/frontend-static '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -1668,6 +1378,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker-native': specifier: workspace:^ version: link:../../host/directory-picker-native + '@deepseek-ai/dsh-host-frontend-static': + specifier: workspace:^ + version: link:../../host/frontend-static '@deepseek-ai/dsh-host-plugin-inventory': specifier: workspace:^ version: link:../../host/plugin-inventory @@ -1695,6 +1408,9 @@ importers: '@deepseek-ai/dsh-storage-json': specifier: workspace:^ version: link:../../storage/storage-json + '@deepseek-ai/dsh-web-frontend': + specifier: workspace:^ + version: link:../../../apps/web '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace @@ -1711,12 +1427,12 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../shell/shell-env '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../shell/shell-env '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1921,49 +1637,6 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/test-support/client-runtime: - dependencies: - '@testing-library/dom': - specifier: ^10.4.1 - version: 10.4.1 - '@testing-library/react': - specifier: ^16.3.2 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - vitest: - specifier: ^4.1.8 - version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-web-react': - specifier: workspace:^ - version: link:../../client/web-react - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - '@types/react-dom': - specifier: ~18.3.0 - version: 18.3.7(@types/react@18.3.31) - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - packages/client/ui-agent-preset: devDependencies: '@deepseek-ai/cordis': @@ -2067,12 +1740,12 @@ importers: '@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-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2131,6 +1804,9 @@ importers: '@deepseek-ai/dsh-client-ui-attachment': specifier: workspace:^ version: link:../ui-attachment + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:^ version: link:../ui-layout @@ -2140,9 +1816,6 @@ importers: '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2299,6 +1972,149 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-goal: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@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-support/client-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-commands': + specifier: workspace:^ + version: link:../../interaction/commands + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-input-trigger: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@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-support/client-runtime + '@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:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-jobs: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@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-support/client-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:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + + packages/client/ui-layout: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-theme': + specifier: workspace:^ + version: link:../ui-theme + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-message-feedback: devDependencies: '@deepseek-ai/cordis': @@ -2350,81 +2166,6 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - packages/client/ui-goal: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@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-support/client-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-commands': - specifier: workspace:^ - version: link:../../interaction/commands - '@deepseek-ai/dsh-goal': - specifier: workspace:^ - version: link:../../goal/goal - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@testing-library/react': - specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - - packages/client/ui-layout: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-ui-theme': - specifier: workspace:^ - version: link:../ui-theme - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-model-selection: devDependencies: '@deepseek-ai/cordis': @@ -2451,12 +2192,12 @@ importers: '@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-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2473,51 +2214,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-settings-models: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-schema-form': - specifier: workspace:^ - version: link:../schema-form - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-web-react': - specifier: workspace:^ - version: link:../web-react - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-permission-presets: devDependencies: '@deepseek-ai/cordis': @@ -2544,15 +2240,15 @@ importers: '@deepseek-ai/dsh-client-ui-commands': specifier: workspace:^ version: link:../ui-commands + '@deepseek-ai/dsh-client-ui-input-trigger': + specifier: workspace:^ + version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-settings': specifier: workspace:^ version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-input-trigger': - specifier: workspace:^ - version: link:../ui-input-trigger '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2614,94 +2310,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-settings-plugins: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@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-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-client-web-react': - specifier: workspace:^ - version: link:../web-react - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - - packages/client/ui-settings-plugin-inventory: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@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-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives - '@deepseek-ai/dsh-client-ui-settings': - specifier: workspace:^ - version: link:../ui-settings - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../ui-slots - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@testing-library/react': - specifier: ^16.1.0 - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - packages/client/ui-primitives: dependencies: '@shikijs/langs': @@ -2778,58 +2386,6 @@ importers: specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - packages/client/ui-user-questions: - dependencies: - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../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 - clsx: - specifier: ^2.0.0 - version: 2.1.1 - react: - specifier: ^18.2.0 - version: 18.3.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-api-remotes': - specifier: workspace:^ - version: link:../../api/remotes - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../locale - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-user-questions': - specifier: workspace:^ - version: link:../../interaction/user-questions - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - packages/client/ui-settings: dependencies: '@deepseek-ai/dsh-client-connection': @@ -2922,6 +2478,139 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-settings-models: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-schema-form': + specifier: workspace:^ + version: link:../schema-form + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/client/ui-settings-plugin-inventory: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@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-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + + packages/client/ui-settings-plugins: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@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-support/client-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-sidebar: dependencies: clsx: @@ -2979,12 +2668,12 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3007,40 +2696,6 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) - packages/client/ui-input-trigger: - dependencies: - clsx: - specifier: ^2.0.0 - version: 2.1.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@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-support/client-runtime - '@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:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/client/ui-slots: devDependencies: '@deepseek-ai/cordis': @@ -3074,12 +2729,12 @@ importers: '@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-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -3096,40 +2751,6 @@ importers: specifier: ~18.3.1 version: 18.3.31 - packages/client/ui-jobs: - dependencies: - react: - specifier: ^18.2.0 - version: 18.3.1 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@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-support/client-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:../../runtime-diagnostics/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - packages/client/ui-theme: dependencies: '@deepseek-ai/dsh-client-connection': @@ -3289,6 +2910,58 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/client/ui-user-questions: + dependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../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 + clsx: + specifier: ^2.0.0 + version: 2.1.1 + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-questions': + specifier: workspace:^ + version: link:../../interaction/user-questions + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + packages/client/ui-workflow-run: dependencies: react: @@ -3614,6 +3287,55 @@ importers: specifier: workspace:^ version: link:../../llm/token-meter + packages/context/agent-instructions: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/context/session-reference: dependencies: '@deepseek-ai/schemastery': @@ -3694,71 +3416,22 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-shell': specifier: workspace:^ version: link:../../shell/shell - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - packages/context/agent-instructions: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-fs': - specifier: workspace:^ - version: link:../../fs/fs - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../fs/fs-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-llm-deepseek': - specifier: workspace:^ - version: link:../../llm/llm-deepseek - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tool-fs': - specifier: workspace:^ - version: link:../../fs/tool-fs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - packages/core/agent: devDependencies: '@deepseek-ai/cordis': @@ -3998,15 +3671,15 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment packages/e2b/e2b: dependencies: @@ -4088,6 +3761,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../context/agent-instructions '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../agent-spine-demo @@ -4115,9 +3791,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../context/agent-instructions packages/examples/agent-spine-demo: dependencies: @@ -4134,12 +3807,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../shell/shell-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -4161,18 +3834,24 @@ importers: '@deepseek-ai/dsh-goal-round-driver': specifier: workspace:^ version: link:../../goal/goal-round-driver + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../llm/llm-retry - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local @@ -4188,6 +3867,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../skill/skill @@ -4200,12 +3882,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../shell/tool-bash @@ -4215,18 +3891,15 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../goal/tool-goal - '@deepseek-ai/dsh-tool-skill': - specifier: workspace:^ - version: link:../../skill/tool-skill '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../skill/tool-skill '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../context/agent-instructions '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry @@ -4244,6 +3917,49 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/extensions/tool-cordis: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/cordis-plugin-timer': + specifier: workspace:^ + version: link:../../../vendor/timer + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/feedback/command-feedback: devDependencies: '@deepseek-ai/cordis': @@ -4258,6 +3974,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../identity/anonymous-user-id '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -4273,9 +3992,6 @@ importers: '@deepseek-ai/dsh-session-telemetry': specifier: workspace:^ version: link:../../session/session-telemetry - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../identity/anonymous-user-id packages/feedback/message-feedback: dependencies: @@ -4743,15 +4459,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell packages/hooks/hooks-claude-code: dependencies: @@ -4771,9 +4487,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -4795,6 +4508,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -4823,9 +4539,6 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../shell/bash-local @@ -4847,6 +4560,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local @@ -4883,6 +4599,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -4916,9 +4635,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -5097,6 +4813,21 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/identity/anonymous-user-id: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/interaction/commands: dependencies: zod: @@ -5137,9 +4868,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../shell/shell '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../commands @@ -5161,6 +4889,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../shell/shell '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval @@ -5235,6 +4966,98 @@ importers: specifier: workspace:^ version: link:../../llm/llm + packages/jobs/jobs: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/jobs/jobs-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + 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-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../jobs + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + + packages/jobs/tool-jobs: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/llm/llm: dependencies: '@deepseek-ai/schemastery': @@ -5269,15 +5092,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../identity/anonymous-user-id '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -5287,9 +5113,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../identity/anonymous-user-id packages/llm/llm-pi-ai: dependencies: @@ -5309,12 +5132,12 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm @@ -5625,15 +5448,15 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -5672,25 +5495,7 @@ importers: specifier: workspace:^ version: link:../../core/system-prompt - packages/terminal/terminal: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - - packages/terminal/terminal-bash: + packages/runtime-diagnostics/invariants: dependencies: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery @@ -5699,143 +5504,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../terminal - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subprocess': - specifier: workspace:^ - version: link:../../subprocess/subprocess - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - - packages/shell/tool-bash-persistent: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - 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-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../../terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../../terminal/terminal-bash - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - - packages/terminal/tool-terminal: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - 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-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../terminal-bash - '@deepseek-ai/dsh-output-retention': - specifier: workspace:^ - version: link:../../util/output-retention - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../sandbox/sandbox-policy - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subprocess-local': - specifier: workspace:^ - version: link:../../subprocess/subprocess-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../jobs/tool-jobs - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools packages/sandbox/sandbox: devDependencies: @@ -6045,49 +5713,6 @@ importers: specifier: workspace:^ version: link:../../subagent/subagent - packages/extensions/tool-cordis: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/cordis-plugin-timer': - specifier: workspace:^ - version: link:../../../vendor/timer - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../test-support/agent-loop-testkit - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - packages/session-query/session-log-download: devDependencies: '@deepseek-ai/cordis': @@ -6455,6 +6080,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../identity/anonymous-user-id '@deepseek-ai/dsh-command-feedback': specifier: workspace:^ version: link:../../feedback/command-feedback @@ -6470,9 +6098,6 @@ importers: '@deepseek-ai/dsh-session-telemetry': specifier: workspace:^ version: link:../session-telemetry - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../identity/anonymous-user-id packages/session/session-title: dependencies: @@ -6592,21 +6217,6 @@ importers: specifier: workspace:^ version: link:../../util/timeout - packages/identity/anonymous-user-id: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-home-paths': - specifier: workspace:^ - version: link:../../util/home-paths - packages/settings/settings: dependencies: '@deepseek-ai/schemastery': @@ -6641,16 +6251,410 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../settings + packages/shell/bash-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + + packages/shell/bash-sandbox: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/node-addon-landlock-run': + specifier: workspace:* + version: link:../../../native/landlock-run/packages/entry + + packages/shell/pwsh-local: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + + packages/shell/pwsh-sandbox: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + + packages/shell/shell: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + + packages/shell/shell-env: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + + packages/shell/tool-bash: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../shell-env + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval + + packages/shell/tool-bash-persistent: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + 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-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../../terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + + packages/shell/tool-pwsh: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../test-support/loader-smoke + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../shell-env + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval + + packages/shell/tool-pwsh-persistent: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + 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-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../pwsh-local + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../../terminal/terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/skill/skill: dependencies: '@deepseek-ai/schemastery': @@ -6700,12 +6704,12 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill @@ -6902,6 +6906,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -6932,9 +6939,6 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -7278,6 +7282,12 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7299,12 +7309,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../../jobs/jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../../jobs/jobs-local '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../jobs/tool-jobs @@ -7414,6 +7418,9 @@ importers: packages/subprocess/subprocess-local: dependencies: + koffi: + specifier: ^3.1.0 + version: 3.1.1 node-pty: specifier: ^1.1.0 version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) @@ -7434,6 +7441,122 @@ importers: specifier: workspace:^ version: link:../../util/timeout + packages/terminal/terminal: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + + packages/terminal/terminal-bash: + dependencies: + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../shell/pwsh-local + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess': + specifier: workspace:^ + version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../terminal + + packages/terminal/tool-terminal: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + 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-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-jobs': + specifier: workspace:^ + version: link:../../jobs/jobs + '@deepseek-ai/dsh-jobs-local': + specifier: workspace:^ + version: link:../../jobs/jobs-local + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-output-retention': + specifier: workspace:^ + version: link:../../util/output-retention + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subprocess-local': + specifier: workspace:^ + version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-terminal': + specifier: workspace:^ + version: link:../terminal + '@deepseek-ai/dsh-terminal-bash': + specifier: workspace:^ + version: link:../terminal-bash + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + packages/test-support/acp-snapshot: dependencies: '@agentclientprotocol/sdk': @@ -7483,15 +7606,48 @@ importers: specifier: workspace:^ version: link:../../core/tools - packages/runtime-diagnostics/invariants: + packages/test-support/client-runtime: dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + vitest: + specifier: ^4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../../client/web-react + '@deepseek-ai/dsh-host-apiproxy': + specifier: workspace:^ + version: link:../../host/apiproxy + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/test-support/llm-mock-server: devDependencies: @@ -7545,98 +7701,6 @@ importers: specifier: workspace:^ version: link:../../core/session - packages/jobs/jobs: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - - packages/jobs/jobs-local: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - 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-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../jobs - '@deepseek-ai/dsh-timeout': - specifier: workspace:^ - version: link:../../util/timeout - - packages/jobs/tool-jobs: - dependencies: - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-output-retention': - specifier: workspace:^ - version: link:../../util/output-retention - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-jobs': - specifier: workspace:^ - version: link:../jobs - '@deepseek-ai/dsh-jobs-local': - specifier: workspace:^ - version: link:../jobs-local - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - packages/todo/tool-todo: dependencies: '@deepseek-ai/schemastery': @@ -7736,14 +7800,7 @@ importers: specifier: ^4.4.3 version: 4.4.3 - packages/typert/registry: - dependencies: - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../protocol - zod: - specifier: ^4.4.3 - version: 4.4.3 + packages/typert/protocol: devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7752,7 +7809,14 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/typert/protocol: + packages/typert/registry: + dependencies: + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../protocol + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7779,6 +7843,15 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/util/home-paths: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + packages/util/launch-environment: devDependencies: '@deepseek-ai/cordis': @@ -7797,15 +7870,6 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - packages/util/home-paths: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - packages/util/output-retention: devDependencies: '@deepseek-ai/cordis': @@ -7932,12 +7996,12 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -7957,12 +8021,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -7976,12 +8040,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../util/launch-environment '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web @@ -8199,24 +8263,24 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent + '@deepseek-ai/dsh-agent-instructions': + specifier: workspace:^ + version: link:../../packages/context/agent-instructions '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../packages/core/agent-loop '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../../packages/examples/agent-spine-demo + '@deepseek-ai/dsh-anonymous-user-id': + specifier: workspace:^ + version: link:../../packages/identity/anonymous-user-id '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/boot/app-boot '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../packages/attachment/attachment - '@deepseek-ai/dsh-shell': - specifier: workspace:^ - version: link:../../packages/shell/shell - '@deepseek-ai/dsh-shell-env': - specifier: workspace:^ - version: link:../../packages/shell/shell-env '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/shell/bash-local @@ -8247,9 +8311,6 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../packages/credentials/credentials - '@deepseek-ai/dsh-launch-environment': - specifier: workspace:^ - version: link:../../packages/util/launch-environment '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../packages/fs/fs @@ -8268,6 +8329,9 @@ importers: '@deepseek-ai/dsh-goal-round-driver': specifier: workspace:^ version: link:../../packages/goal/goal-round-driver + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../packages/util/home-paths '@deepseek-ai/dsh-hook-protocol': specifier: workspace:^ version: link:../../packages/hooks/hook-protocol @@ -8280,12 +8344,15 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../packages/runtime-diagnostics/invariants - '@deepseek-ai/dsh-sdk-jsonrpc-server': + '@deepseek-ai/dsh-jobs': specifier: workspace:^ - version: link:../../packages/sdk/server - '@deepseek-ai/dsh-sdk-jsonrpc-demo': + version: link:../../packages/jobs/jobs + '@deepseek-ai/dsh-jobs-local': specifier: workspace:^ - version: link:../../packages/examples/jsonrpc-demo + version: link:../../packages/jobs/jobs-local + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../packages/util/launch-environment '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../packages/llm/llm @@ -8298,27 +8365,18 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry - '@deepseek-ai/dsh-home-paths': + '@deepseek-ai/dsh-output-retention': specifier: workspace:^ - version: link:../../packages/util/home-paths + version: link:../../packages/util/output-retention '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../packages/interaction/permission-presets '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode - '@deepseek-ai/dsh-terminal': - specifier: workspace:^ - version: link:../../packages/terminal/terminal - '@deepseek-ai/dsh-terminal-bash': - specifier: workspace:^ - version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-repeat-tool-reminder': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-reminder - '@deepseek-ai/dsh-output-retention': - specifier: workspace:^ - version: link:../../packages/util/output-retention '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../packages/sandbox/sandbox @@ -8331,6 +8389,12 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../packages/core/scope + '@deepseek-ai/dsh-sdk-jsonrpc-demo': + specifier: workspace:^ + version: link:../../packages/examples/jsonrpc-demo + '@deepseek-ai/dsh-sdk-jsonrpc-server': + specifier: workspace:^ + version: link:../../packages/sdk/server '@deepseek-ai/dsh-sdk-protocol': specifier: workspace:^ version: link:../../packages/sdk/protocol @@ -8367,6 +8431,12 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../packages/settings/settings + '@deepseek-ai/dsh-shell': + specifier: workspace:^ + version: link:../../packages/shell/shell + '@deepseek-ai/dsh-shell-env': + specifier: workspace:^ + version: link:../../packages/shell/shell-env '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../../packages/skill/skill @@ -8397,18 +8467,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../packages/core/system-prompt - '@deepseek-ai/dsh-jobs': + '@deepseek-ai/dsh-terminal': specifier: workspace:^ - version: link:../../packages/jobs/jobs - '@deepseek-ai/dsh-jobs-local': + version: link:../../packages/terminal/terminal + '@deepseek-ai/dsh-terminal-bash': specifier: workspace:^ - version: link:../../packages/jobs/jobs-local + version: link:../../packages/terminal/terminal-bash '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../packages/util/timeout - '@deepseek-ai/dsh-tool-call-timeout-policy': - specifier: workspace:^ - version: link:../../packages/guard/timeout-policy '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../packages/llm/token-meter @@ -8421,6 +8488,9 @@ importers: '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:^ version: link:../../packages/shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-call-timeout-policy': + specifier: workspace:^ + version: link:../../packages/guard/timeout-policy '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/extensions/tool-cordis @@ -8430,6 +8500,9 @@ importers: '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-jobs': + specifier: workspace:^ + version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -8442,9 +8515,6 @@ importers: '@deepseek-ai/dsh-tool-subagent-control': specifier: workspace:^ version: link:../../packages/subagent/tool-subagent-control - '@deepseek-ai/dsh-tool-jobs': - specifier: workspace:^ - version: link:../../packages/jobs/tool-jobs '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../packages/todo/tool-todo @@ -8463,9 +8533,6 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../packages/interaction/user-approval - '@deepseek-ai/dsh-anonymous-user-id': - specifier: workspace:^ - version: link:../../packages/identity/anonymous-user-id '@deepseek-ai/dsh-user-questions': specifier: workspace:^ version: link:../../packages/interaction/user-questions @@ -8490,9 +8557,6 @@ importers: '@deepseek-ai/dsh-workflow-worker-thread': specifier: workspace:^ version: link:../../packages/workflow/workflow-worker-thread - '@deepseek-ai/dsh-agent-instructions': - specifier: workspace:^ - version: link:../../packages/context/agent-instructions '@deepseek-ai/schemastery': specifier: link:../../vendor/schemastery version: link:../../vendor/schemastery diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index e686081812..cc708b0227 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -281,11 +281,11 @@ const TOOL_PACKAGES: ToolPackage[] = [ { pkg: '@deepseek-ai/dsh-tool-pwsh-persistent', dir: 'tool-pwsh-persistent', - source: 'packages/pty/tool-pwsh-persistent/src/index.ts', - requires: ['ctx.tools', 'ctx.pty', 'an owning Agent at execution time'], + source: 'packages/shell/tool-pwsh-persistent/src/index.ts', + requires: ['ctx.tools', 'ctx.terminals', 'an owning Agent at execution time'], writes: ['tool/call', 'PTY shell state', 'tool/result'], async mount(ctx) { - await ctx.plugin(PtyService) + await ctx.plugin(TerminalSessionService) await ctx.plugin(ToolPwshPersistent) }, note: From 7da062f61bd16460193849ca4dfe9ee9c10f3422 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:15:35 +0800 Subject: [PATCH 029/137] docs: refresh catalogs, links, and pairs for the renamed persistent pwsh stack --- .../2026-08-11-pwsh-persistent-pty.i18n.yaml | 4 +-- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 33 ++++++++++++++++--- docs/config-catalog.zh.md | 33 ++++++++++++++++--- docs/tool-catalog.i18n.yaml | 4 +-- docs/tool-catalog.md | 26 +++++++++++++++ docs/tool-catalog.zh.md | 26 +++++++++++++++ packages/shell/tool-pwsh/README.i18n.yaml | 4 +-- packages/shell/tool-pwsh/README.md | 2 +- packages/shell/tool-pwsh/README.zh.md | 2 +- packages/terminal/terminal-bash/src/config.ts | 1 + 11 files changed, 121 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index 59ea231ed3..ce1e88c050 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 7d4fe5e21fd4f9d96cfdf54dfbc6273f8aab3b45 -2026-08-11-pwsh-persistent-pty.zh.md: b1bb90218e15617d4445936abe1be19a537ef9f7 +2026-08-11-pwsh-persistent-pty.md: 092302ec001909683f9b7056e982309889c7f23e +2026-08-11-pwsh-persistent-pty.zh.md: 857f78c66f109ababe8a5961fa923d819857fd88 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d2a2391c44..cea7657b39 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: f7cfea66097cd8e2a9022a32d6459bd27ad9adcf -config-catalog.zh.md: ef1e19dc704fe14a6b1a41215573a22c9f7590a1 +config-catalog.md: 6ce672f7249e6784ec550860d2d9a5d3aa84d30d +config-catalog.zh.md: 3e1b370592a50a208bce7d9fa4b1d4b57726822d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f7cfea6609..6ce672f724 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2074,9 +2074,11 @@ Requires: `terminals` · `sandboxPolicy` · `subprocess` export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -2104,9 +2106,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -Source: [`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal/terminal-bash/src/config.ts) +Source: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ## `@deepseek-ai/dsh-time-context` @@ -2179,7 +2184,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:437`](../packages/shell/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2330,6 +2335,26 @@ export interface Config { Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +Requires: `tools` · `terminals` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` Requires: `tools` · `workflowEngine` · `subagents` · `systemPrompt` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ef1e19dc70..3e1b370592 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2076,9 +2076,11 @@ export interface Config { export interface Config { /** Backend registry type (default: `shell`). */ backendType?: string - /** Interactive shell executable (default: `/bin/bash`). */ + /** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */ + shellDialect?: ShellDialect + /** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */ shellPath?: string - /** Shell arguments (default: `--noprofile --norc -i`). */ + /** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */ shellArgs?: string[] /** Terminal rows. */ rows?: number @@ -2106,9 +2108,12 @@ export interface Config { /** Grace before teardown escalates to `SIGKILL`. */ disposeGraceMs?: number } + +/** One supported interactive shell dialect. */ +export type ShellDialect = 'bash' | 'pwsh' ``` -来源:[`packages/terminal/terminal-bash/src/config.ts:6`](../packages/terminal/terminal-bash/src/config.ts) +来源:[`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ## `@deepseek-ai/dsh-time-context` @@ -2181,7 +2186,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) +来源:[`packages/shell/tool-bash-persistent/src/index.ts:437`](../packages/shell/tool-bash-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` @@ -2331,6 +2336,26 @@ export interface Config { 来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +需要:`tools` · `terminals` + +```ts config-catalog +/** Configuration for the persistent pwsh tool. */ +export interface Config { + /** PTY backend used for each owner-isolated persistent shell (default `shell`). */ + backendType?: string + /** Wall-clock limit for one command (default 300000). */ + timeoutMs?: number + /** Maximum returned command-output characters before clipping (default 16000). */ + maxOutputChars?: number + /** Model-facing tool description; deployments may describe their environment. */ + description?: string +} +``` + +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) + ## `@deepseek-ai/dsh-tool-ralph` 需要:`tools` · `workflows` · `subagents` · `systemPrompt` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 12b34cd0f7..62ef725fbc 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: 1a28d560ab9fea3ca8e68856377e7912184d5311 -tool-catalog.zh.md: 1572c84f3b013e9a46198e1503d5373495845d28 +tool-catalog.md: f4fb7fe2a7ae65f779ae5ded146098deb2be9ad0 +tool-catalog.zh.md: 49ea51446e93c2224bd8f173e6f473e439f6100a diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 1a28d560ab..f4fb7fe2a7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,6 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@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.terminals`, `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-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect 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 API. | | `@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-observation-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 jobs) — 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. | @@ -352,6 +353,31 @@ Source: [`packages/shell/tool-bash-persistent/src/index.ts`](../packages/shell/t One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent. + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts`](../packages/shell/tool-pwsh-persistent/src/index.ts) + +One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 1572c84f3b..49ea51446e 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,6 +24,7 @@ | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@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.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | +| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 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`、`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-observation-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 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | @@ -354,6 +355,31 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 +## `@deepseek-ai/dsh-tool-pwsh-persistent` + +### `pwsh` + +在持久 PowerShell shell 中运行命令。包括当前目录和已导出环境变量在内的状态会在此 agent 的多次调用之间保留。 + +```json +{ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] +} +``` + +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts`](../packages/shell/tool-pwsh-persistent/src/index.ts) + +一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 + ## `@deepseek-ai/dsh-tool-str-replace-editor` ### `str_replace_editor` diff --git a/packages/shell/tool-pwsh/README.i18n.yaml b/packages/shell/tool-pwsh/README.i18n.yaml index 7dc6bee028..589769709c 100644 --- a/packages/shell/tool-pwsh/README.i18n.yaml +++ b/packages/shell/tool-pwsh/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/shell/tool-pwsh/README.md -README.md: e146a8cf2e47cf827d7b83a5aad810e10017a018 -README.zh.md: 926b24e807a37dc4f49308e082256d64b7f38d38 +README.md: e862fcf0ca85d0ecb0a5fe6cff3ee3c7a8153716 +README.zh.md: e03a980acfe05583721a1f084cbf53545c076126 diff --git a/packages/shell/tool-pwsh/README.md b/packages/shell/tool-pwsh/README.md index e146a8cf2e..e862fcf0ca 100644 --- a/packages/shell/tool-pwsh/README.md +++ b/packages/shell/tool-pwsh/README.md @@ -121,6 +121,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. -- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh. +- **No persistent shell** — every call starts a fresh `pwsh -Command`; the persistent-shell counterpart is [`@deepseek-ai/dsh-tool-pwsh-persistent`](../tool-pwsh-persistent/README.md), which keeps one owner-scoped pwsh alive across calls on Windows (ConPTY) and POSIX hosts with pwsh. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/shell/tool-pwsh/README.zh.md b/packages/shell/tool-pwsh/README.zh.md index 926b24e807..e03a980acf 100644 --- a/packages/shell/tool-pwsh/README.zh.md +++ b/packages/shell/tool-pwsh/README.zh.md @@ -121,6 +121,6 @@ ack 是固定短行;任务输出按读取有界。 ## 已知限制与暂缓事项 - **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 -- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../../pty/tool-pwsh-persistent/README.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。 +- **无持久 shell** — 每次调用都启动全新的 `pwsh -Command`;持久 shell 对应物是 [`@deepseek-ai/dsh-tool-pwsh-persistent`](../tool-pwsh-persistent/README.md),它在 Windows(ConPTY)以及装有 pwsh 的 POSIX 主机上跨调用保持一个 owner 作用域的 pwsh 存活。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index bf78a56361..5752845549 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -96,6 +96,7 @@ export const Config: z = z.object({ /** * Assert every effective numeric config field is a positive safe integer and bounds compose. * @param config - Schemastery-resolved plugin configuration. + * @returns Narrows the input to the fully resolved configuration. */ export function validateConfig(config: Config): asserts config is ResolvedConfig { const resolved = config as ResolvedConfig From 82c53ee209d164c91cc42bfa3eb7105149af94e1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:40:26 +0800 Subject: [PATCH 030/137] fix(terminal-bash): fall back to dialect defaults when Schemastery materializes empty shell values --- packages/terminal/terminal-bash/src/config.ts | 14 ++++++++++---- .../terminal/terminal-bash/tests/config.spec.ts | 11 +++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index 5752845549..19fada0c43 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -59,8 +59,10 @@ export const DEFAULT_PWSH_ARGS = ['-NoLogo', '-NoProfile'] /** * Resolve the effective per-dialect shell specification. Defaulting is this - * explicit step: an unset `shellPath`/`shellArgs` selects the dialect's - * defaults, while an explicit value always wins. + * explicit step: an unset or empty `shellPath`/`shellArgs` selects the + * dialect's defaults, while a non-empty explicit value always wins. + * (Schemastery materializes an absent optional array as `[]`, so emptiness — + * not just `undefined` — means "dialect default".) * @param config - Schemastery-resolved plugin configuration. * @returns the fully resolved configuration. */ @@ -69,8 +71,12 @@ export function resolveConfig(config: Config): ResolvedConfig { return { ...(config as Required), shellDialect, - shellPath: config.shellPath ?? (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL), - shellArgs: config.shellArgs ?? (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS), + shellPath: config.shellPath !== undefined && config.shellPath.length > 0 + ? config.shellPath + : (shellDialect === 'pwsh' ? resolvePwshPath() : DEFAULT_BASH_SHELL), + shellArgs: config.shellArgs !== undefined && config.shellArgs.length > 0 + ? config.shellArgs + : (shellDialect === 'pwsh' ? DEFAULT_PWSH_ARGS : DEFAULT_BASH_ARGS), } } diff --git a/packages/terminal/terminal-bash/tests/config.spec.ts b/packages/terminal/terminal-bash/tests/config.spec.ts index 09a33c5ff9..d7557a2d90 100644 --- a/packages/terminal/terminal-bash/tests/config.spec.ts +++ b/packages/terminal/terminal-bash/tests/config.spec.ts @@ -54,6 +54,17 @@ describe('terminal-bash dialect resolution', () => { expect(resolved.shellArgs).toEqual(['-NoProfile']) }) + it('treats empty shell values as unset so Schemastery materialization cannot drop the dialect defaults', () => { + // Schemastery materializes an absent optional array as `[]`; the resolver + // must treat that shape like an unset value or a real bash spawn would + // start non-interactive without the controlled prompt. + const resolved = resolveConfig({ + backendType: 'shell', shellDialect: 'bash', shellPath: '', shellArgs: [], rows: 24, cols: 80, + }) + expect(resolved.shellPath).toBe('/bin/bash') + expect(resolved.shellArgs).toEqual(['--noprofile', '--norc', '-i']) + }) + it('validates the effective shell path, not only the raw one', () => { expect(() => { validateConfig(resolveConfig({ backendType: 'shell', shellDialect: 'bash', rows: 24, cols: 80 })) }).not.toThrow() expect(() => { validateConfig(resolveConfig({ backendType: 'shell', shellDialect: 'pwsh', rows: 24, cols: 80 })) }).not.toThrow() From 13d859a6199040e16a720fc559dd741a41cb487b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:47:44 +0800 Subject: [PATCH 031/137] test(subprocess): measure the pipe-drain settle from before the pid-file handoff --- packages/subprocess/subprocess-local/tests/spawn.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index ba214201be..1f21dfaaa1 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -857,8 +857,11 @@ describe('coverage seams', () => { ...spec('unused', { graceMs: 100 }), argv: [process.execPath, '-e', childScript], }) - const helper = await waitForPidFile(pidFile) + // The drain timer starts when the child's stdio closes, which can precede + // the pid file becoming visible; measure from before that wait so the + // lower bound cannot be eroded by the pid-file handoff. const started = Date.now() + const helper = await waitForPidFile(pidFile) const outcome = await running.done expect(outcome.exitCode).toBe(0) expect(Date.now() - started).toBeGreaterThanOrEqual(90) From 6e2a4fd08af25f60e4a193f98b5d79dfbca3685b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 10:54:23 +0800 Subject: [PATCH 032/137] test(terminal-bash): cover the spawn signal forwarded into the pwsh bootstrap send --- .../terminal-bash/tests/index.spec.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index 571df73474..c1efd399d8 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -432,6 +432,39 @@ describe('BashTerminalBackend startup rollback', () => { const timedOut = new BashTerminalBackend(ctx, { ...config(), shellDialect: 'pwsh' }, async () => terminalHandle(), () => sessionFor('timeout')) await expect(timedOut.spawn(spec(agent(ctx)))).rejects.toThrow('did not reach readiness before startup timeout') }) + + it('forwards the spawn signal into the pwsh bootstrap sends', async () => { + const ctx = new Context() + await ctx.plugin(EmptySandbox) + await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' }) + const sends: TerminalSendRequest[] = [] + const session = { + motd: '', + startSend: (request: TerminalSendRequest) => { + sends.push(request) + return { + done: Promise.resolve({ + viewport: 'dsh> ', waitReason: 'stdin_read' as const, + sessionStatus: { kind: 'running' as const }, truncated: false, + }), + readOutput: () => ({ delta: '', truncated: false }), + cancel: () => false, + } + }, + read: () => ({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }), + } as unknown as LocalPtySession + const backend = new BashTerminalBackend( + ctx, + { ...config(), shellDialect: 'pwsh', shellPath: 'pwsh' }, + async () => terminalHandle(), + () => session, + ) + const signal = new AbortController().signal + const spawned = await backend.spawn({ ...spec(agent(ctx)), signal }) + expect(spawned.motd).toBe('dsh> ') + expect(sends).toHaveLength(1) + expect(sends[0]?.signal).toBe(signal) + }) }) describe('terminal-bash plugin shape', () => { From b89808cc2d9e9a3c8240e1ac64bc43ff861ab0c8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 11:10:59 +0800 Subject: [PATCH 033/137] fix(gates): align package version, module graph, and jscpd ignores for the mirrored pwsh stack --- docs/module-graph.md | 7 +++++++ packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh-persistent/src/index.ts | 4 ++++ .../subprocess/subprocess-local/src/windows-inspector.ts | 4 ++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index dc2ef337d9..3aa3efd323 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -282,6 +282,7 @@ flowchart TD pkg_tool_bash["tool-bash"] pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pwsh["tool-pwsh"] + pkg_tool_pwsh_persistent["tool-pwsh-persistent"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -935,6 +936,11 @@ flowchart TD pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools + pkg_tool_pwsh_persistent --> pkg_agent + pkg_tool_pwsh_persistent --> pkg_invariants + pkg_tool_pwsh_persistent --> pkg_terminal + pkg_tool_pwsh_persistent --> pkg_timeout + pkg_tool_pwsh_persistent --> pkg_tools pkg_tool_terminal --> pkg_agent pkg_tool_terminal --> pkg_invariants pkg_tool_terminal --> pkg_jobs @@ -1538,6 +1544,7 @@ flowchart TD | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 5875376802..4f2fc37146 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.0.1-rc.1", + "version": "0.0.1-rc.2", "publishConfig": { "access": "restricted" }, diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index cce7ddd001..5fc2d3a03d 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -1,3 +1,5 @@ +/* jscpd:ignore-start -- deliberate mirror of tool-bash-persistent (persistent-pty note 2026-08-11-pwsh-persistent-pty): + the PowerShell counterpart shares the session registry, polling loop, and reset contract by design. */ /** * Model-facing persistent `pwsh` tool over the owner-scoped PTY seam. * @module @deepseek-ai/dsh-tool-pwsh-persistent @@ -508,3 +510,5 @@ export function apply(ctx: Context, config: Config): void { } registerPersistentPwsh(ctx, resolved) } + +/* jscpd:ignore-end */ diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index da5158b4c0..9c306f595f 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -38,6 +38,9 @@ export interface WindowsProcessInspectorInternals { * @param started - creation-time identity resolver for one member. * @returns the root and its current transitive descendants, children first. */ +/* jscpd:ignore-start -- the Windows inspector deliberately mirrors process-inspector.ts: + the decision logic (tree walk, identity fencing, group signalling) is the same contract over + Win32 primitives, per the persistent-pty note 2026-08-11-pwsh-persistent-pty. */ export function windowsProcessTree( entries: ProcessEntry[], rootPid: number, @@ -106,6 +109,7 @@ export class WindowsProcessInspector implements ProcessInspector { if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') } } +/* jscpd:ignore-end */ /** * Create the Windows process inspector. From 68ba98c29e6f54a3a3c9ef5edffc56456ba9f970 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Thu, 13 Aug 2026 11:24:43 +0800 Subject: [PATCH 034/137] docs: mirror the pwsh-persistent graph nodes and source lines into the Chinese counterparts --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.zh.md | 7 +++++++ 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index cea7657b39..717f0293c7 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: 6ce672f7249e6784ec550860d2d9a5d3aa84d30d -config-catalog.zh.md: 3e1b370592a50a208bce7d9fa4b1d4b57726822d +config-catalog.md: 09106e25600851705d102d2265354404f2369a8d +config-catalog.zh.md: 12d7487719a553e5152ca529b30540aa7c8eaafd diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6ce672f724..09106e2560 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2353,7 +2353,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) +Source: [`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3e1b370592..12d7487719 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2354,7 +2354,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:470`](../packages/shell/tool-pwsh-persistent/src/index.ts) +来源:[`packages/shell/tool-pwsh-persistent/src/index.ts:472`](../packages/shell/tool-pwsh-persistent/src/index.ts) ## `@deepseek-ai/dsh-tool-ralph` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a2345a28c5..152ba8d0c7 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: dc2ef337d946b37d4d0a8600c70c26c53cc89dc0 -module-graph.zh.md: 8cd2feff369752cb0c9a9afd38a77f31ddf29826 +module-graph.md: 3aa3efd323836e48e79b22018f8c85a1869a22f2 +module-graph.zh.md: 438ac5208e3471ac11286a30cd82966f481a8de9 diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8cd2feff36..438ac5208e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -284,6 +284,7 @@ flowchart TD pkg_tool_bash["tool-bash"] pkg_tool_bash_persistent["tool-bash-persistent"] pkg_tool_pwsh["tool-pwsh"] + pkg_tool_pwsh_persistent["tool-pwsh-persistent"] end subgraph group_storage["packages/storage"] pkg_storage["storage"] @@ -937,6 +938,11 @@ flowchart TD pkg_tool_bash_persistent --> pkg_terminal pkg_tool_bash_persistent --> pkg_timeout pkg_tool_bash_persistent --> pkg_tools + pkg_tool_pwsh_persistent --> pkg_agent + pkg_tool_pwsh_persistent --> pkg_invariants + pkg_tool_pwsh_persistent --> pkg_terminal + pkg_tool_pwsh_persistent --> pkg_timeout + pkg_tool_pwsh_persistent --> pkg_tools pkg_tool_terminal --> pkg_agent pkg_tool_terminal --> pkg_invariants pkg_tool_terminal --> pkg_jobs @@ -1540,6 +1546,7 @@ flowchart TD | [`pwsh-sandbox`](../packages/shell/pwsh-sandbox) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`pwsh-local`](../packages/shell/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | +| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | From e20f560992417fdfc15bbd5d5201838272463d69 Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 14 Aug 2026 13:06:21 +0800 Subject: [PATCH 035/137] feat(python-sdk): support bundled preset runtime dependencies --- packages/fs/tool-fs-search/src/search-core.ts | 18 ++- .../shell/tool-bash-persistent/src/index.ts | 3 +- .../tests/loader-composition.spec.ts | 6 +- .../tool-bash-persistent/tests/tools.spec.ts | 4 +- .../subprocess-local/src/process-inspector.ts | 80 ++++++++++-- .../subprocess-local/src/terminal.ts | 16 ++- .../tests/process-inspector.spec.ts | 35 +++++ .../terminal/terminal-bash/src/sanitize.ts | 5 +- packages/terminal/terminal/src/index.ts | 3 + pnpm-lock.yaml | 18 +++ python/sdk-runtime/hatch_build.py | 2 +- python/sdk-runtime/package.json | 6 + .../src/deepseek_harness_runtime/__init__.py | 10 +- python/sdk/tests/test_release_version.py | 4 + scripts/build-exe-for-python-sdk.ts | 29 ++++- scripts/build-python-release.py | 3 +- scripts/smoke-python-runtime.py | 95 +++++++++++++- scripts/verify-runtime-closure.ts | 123 ++++++++++++++++-- 18 files changed, 410 insertions(+), 50 deletions(-) diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 1dddb99373..e4580f7301 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -19,6 +19,7 @@ * @module @deepseek-ai/dsh-tool-fs-search/search-core */ +import { existsSync } from 'node:fs' import { isAbsolute, relative, sep } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -158,18 +159,21 @@ let rgPathPromise: Promise | undefined /** * The packaged ripgrep binary path, resolved lazily once per process. * - * `@vscode/ripgrep` resolves its platform package (`@vscode/ripgrep- - * -`) at module evaluation, so a static import would turn a missing or - * corrupt platform package (`pnpm install --omit=optional`, partial install) - * into a failure of the whole Loader composition. Resolving at the call - * boundary keeps that failure at the first search call as `SEARCH_FAILED` — - * the package's documented no-load-time-probe contract. + * A single-file runtime uses the executable's `-rg` sidecar because a native + * helper cannot be spawned from pkg's virtual filesystem. Node-mode builds + * fall back to the platform package selected by `@vscode/ripgrep`. Resolving + * at the call boundary keeps a missing or corrupt binary at the first search + * call as `SEARCH_FAILED`, rather than failing the Loader composition. * * @returns the packaged binary's absolute path; the memoized promise rejects * when the platform package cannot be resolved. */ export function resolveRgPath(): Promise { - rgPathPromise ??= import('@vscode/ripgrep').then(module => module.rgPath) + rgPathPromise ??= Promise.resolve().then(async () => { + const executableSidecar = `${process.execPath}-rg` + if (existsSync(executableSidecar)) return executableSidecar + return (await import('@vscode/ripgrep')).rgPath + }) return rgPathPromise } diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index 16d127bbe2..b13911cb25 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -15,7 +16,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' -const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' +const SHELL_PROMPT = CONTROLLED_PROMPT const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' // One page is enough to find a just-emitted completion marker; the full // scrollback is assembled only when a command settles or needs partial output. diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index 55e89bc9f4..f04981f2cd 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -84,8 +84,8 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { ' config:', ' pollIntervalMs: 10', ' exactProbeAfterMs: 20', - ' idleSilenceMs: 100', - ' handoffGraceMs: 100', + ' idleSilenceMs: 3000', + ' handoffGraceMs: 500', ' scrollbackLines: 20000', ' timeoutMs: 2000', ' disposeGraceMs: 500', @@ -131,7 +131,9 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { }) expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash']) + const startedAt = Date.now() await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested') + expect(Date.now() - startedAt).toBeLessThan(2_000) const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"')) expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) expect(observed).not.toContain('DSH_PERSISTENT_BASH') diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index b3de46643c..88a9ffa3f4 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import TerminalSessionService from '@deepseek-ai/dsh-terminal' +import TerminalSessionService, { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' import type { TerminalBackend, TerminalBackendSession, @@ -100,7 +100,7 @@ type StubMode = | 'paged-scrollback' class StubPtySession implements TerminalBackendSession { - readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' + readonly motd = CONTROLLED_PROMPT readonly pid = 123 statusValue: TerminalSessionStatus = { kind: 'running' } scrollback = this.motd diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index f31de010de..06a44665cf 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -13,9 +13,9 @@ export interface ProcessIdentity { /** Injectable OS process operations used by one local PTY session. */ export interface ProcessInspector { foregroundPgid(shellPid: number): number | undefined - isStdinWaiting(pgid: number): boolean + isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean /** Return the root and its current transitive descendants, children first. */ - processTree(rootPid: number): ProcessIdentity[] + processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[] /** Return current members of one POSIX process session when the platform exposes them. */ processSession(sessionId: number): ProcessIdentity[] /** Return whether the exact identity remains a non-quiescent process. */ @@ -226,12 +226,26 @@ function syscallWaitsOnStdin( return false } +function processWaitsOnStdin( + internals: ProcessInspectorInternals, + pid: number, + processGroupId: number, + table: SyscallTable, +): boolean { + if (readLinuxStat(internals, pid)?.pgrp !== processGroupId) return false + for (const tid of numericEntries(internals, `/proc/${pid}/task`)) { + const syscall = readSyscall(internals, pid, tid) + if (syscall !== undefined && syscallWaitsOnStdin(internals, pid, syscall, table)) return true + } + return false +} + abstract class PosixProcessInspector implements ProcessInspector { constructor(protected readonly internals: ProcessInspectorInternals) {} abstract foregroundPgid(shellPid: number): number | undefined - abstract isStdinWaiting(pgid: number): boolean - abstract processTree(rootPid: number): ProcessIdentity[] + abstract isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean + abstract processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[] abstract processSession(sessionId: number): ProcessIdentity[] abstract isAlive(identity: ProcessIdentity): boolean @@ -270,6 +284,34 @@ function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdent return result } +function linuxProcessTreeFromChildren( + internals: ProcessInspectorInternals, + rootPid: number, +): ProcessIdentity[] | undefined { + const root = readLinuxStat(internals, rootPid) + if (root === undefined) return [] + const visited = new Set() + const result: ProcessIdentity[] = [] + const visit = (entry: ProcStat): boolean => { + if (visited.has(entry.pid)) return true + visited.add(entry.pid) + let children: string + try { + children = internals.readFile(`/proc/${entry.pid}/task/${entry.pid}/children`) + } catch (_unreadableChildren) { + return false + } + for (const token of children.trim().split(/\s+/)) { + if (token.length === 0 || !/^\d+$/.test(token)) continue + const child = readLinuxStat(internals, Number(token)) + if (child !== undefined && !visit(child)) return false + } + result.push({ pid: entry.pid, started: entry.started }) + return true + } + return visit(root) ? result : undefined +} + class LinuxProcessInspector extends PosixProcessInspector { constructor( private readonly arch: NodeJS.Architecture, @@ -283,20 +325,32 @@ class LinuxProcessInspector extends PosixProcessInspector { return tpgid !== undefined && tpgid > 0 ? tpgid : undefined } - isStdinWaiting(pgid: number): boolean { + isStdinWaiting(pgid: number, scanNamespace = true): boolean { const table = SYSCALLS[this.arch] if (table === undefined) return false + // A POSIX process group is normally led by PID == PGID. Interactive shells + // wait on stdin in that leader, so inspect it before walking the whole PID + // namespace. Large container PID namespaces otherwise make every PTY + // readiness poll scan thousands of unrelated processes. + if (processWaitsOnStdin(this.internals, pgid, pgid, table)) return true + if (!scanNamespace) return false for (const pid of numericEntries(this.internals, '/proc')) { - if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue - for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) { - const syscall = readSyscall(this.internals, pid, tid) - if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true - } + if (pid === pgid) continue + if (processWaitsOnStdin(this.internals, pid, pgid, table)) return true } return false } - processTree(rootPid: number): ProcessIdentity[] { + processTree(rootPid: number, scanNamespace = true): ProcessIdentity[] { + // Linux exposes each process's direct children without requiring a scan of + // the container's whole PID namespace. Fall back for kernels or procfs + // mounts that do not provide the children file. + const rooted = linuxProcessTreeFromChildren(this.internals, rootPid) + if (rooted !== undefined) return rooted + if (!scanNamespace) { + const root = readLinuxStat(this.internals, rootPid) + return root === undefined ? [] : [{ pid: root.pid, started: root.started }] + } const entries = numericEntries(this.internals, '/proc').flatMap((pid) => { const stat = readLinuxStat(this.internals, pid) return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }] @@ -338,11 +392,11 @@ class MacProcessInspector extends PosixProcessInspector { } } - isStdinWaiting(_pgid: number): boolean { + isStdinWaiting(_pgid: number, _scanNamespace = true): boolean { return false } - processTree(rootPid: number): ProcessIdentity[] { + processTree(rootPid: number, _scanNamespace = true): ProcessIdentity[] { return processTree(macProcessTable(this.internals), rootPid) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 6d818c8a7f..bfba98dfb9 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -57,7 +57,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private readonly graceMs: number, ) { this.pid = terminal.pid - this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) + this.rootIdentity = inspector.processTree(this.pid, false).find(member => member.pid === this.pid) this.done = this.outcome.promise this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) }) this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => { @@ -81,12 +81,14 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { // Local inspection is synchronous; the seam returns a promise for remote transports. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async inspectForeground(): Promise { - this.descendants() + // Readiness polling may run every few milliseconds. Track the rooted tree + // here, but reserve the full process-session sweep for teardown. + this.descendants(false, false) const processGroupId = this.inspector.foregroundPgid(this.pid) if (processGroupId === undefined) return undefined return { processGroupId, - inputWaiting: this.inspector.isStdinWaiting(processGroupId), + inputWaiting: this.inspector.isStdinWaiting(processGroupId, false), } } @@ -141,20 +143,22 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { return members.filter(member => this.inspector.isAlive(member)) } - private descendants(): ProcessIdentity[] { + private descendants(includeSession = true, scanNamespace = true): ProcessIdentity[] { // Adopt newly scanned members only while the numeric root pid provably // still carries the spawned shell's start identity: after the shell dies, // a recycled pid's tree and session must not donate an unrelated // process's children to this session's signalling. Already-adopted // members keep their own start identities, which every signal rechecks. - const tree = this.inspector.processTree(this.pid) + const tree = this.inspector.processTree(this.pid, scanNamespace) const root = tree.find(member => member.pid === this.pid) const rootVerified = this.rootIdentity !== undefined && root !== undefined && root.started === this.rootIdentity.started this.trackedDescendants = this.survivors(this.unionMembers( this.trackedDescendants, - ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : [], + ...rootVerified + ? [tree, ...includeSession ? [this.inspector.processSession(this.pid)] : []] + : [], ).filter(member => member.pid !== this.pid)) return this.trackedDescendants } diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index c90a7b3490..84cae7564a 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -95,6 +95,9 @@ describe('Linux process inspector', () => { fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501')) fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10)) fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12)) + fake.files.set('/proc/10/task/10/children', '12') + fake.files.set('/proc/12/task/12/children', '13') + fake.files.set('/proc/13/task/13/children', '') const inspector = createProcessInspector('linux', 'x64', fake.internals) expect(inspector.foregroundPgid(10)).toBe(40) expect(inspector.foregroundPgid(11)).toBeUndefined() @@ -124,6 +127,28 @@ describe('Linux process inspector', () => { expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']]) }) + it('walks a rooted process tree without enumerating the PID namespace', () => { + const fake = fakeInternals() + fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) + fake.files.set('/proc/10/task/10/children', '11') + fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) + fake.files.set('/proc/11/task/11/children', '') + + expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ + { pid: 11, started: '501' }, + { pid: 10, started: '500' }, + ]) + }) + + it('keeps readiness inspection local when procfs has no children index', () => { + const fake = fakeInternals() + fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) + const inspector = createProcessInspector('linux', 'x64', fake.internals) + + expect(inspector.processTree(10, false)).toEqual([{ pid: 10, started: '500' }]) + expect(inspector.isStdinWaiting(10, false)).toBe(false) + }) + it('detects read, select, poll, and epoll waits across non-leader threads', () => { const fake = fakeInternals() fake.dirs.set('/proc', ['100', '101']) @@ -156,6 +181,16 @@ describe('Linux process inspector', () => { expect(inspector.isStdinWaiting(77)).toBe(true) }) + it('checks a waiting process-group leader without scanning the PID namespace', () => { + const fake = fakeInternals() + fake.files.set('/proc/77/stat', stat(77, 77, 77, 77, '1')) + fake.dirs.set('/proc/77/task', ['77']) + fake.files.set('/proc/77/task/77/syscall', syscall(0, 0)) + + const inspector = createProcessInspector('linux', 'x64', fake.internals) + expect(inspector.isStdinWaiting(77)).toBe(true) + }) + it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => { const fake = fakeInternals() fake.dirs.set('/proc', ['100']) diff --git a/packages/terminal/terminal-bash/src/sanitize.ts b/packages/terminal/terminal-bash/src/sanitize.ts index 1f28315fb1..73f9d74e7b 100644 --- a/packages/terminal/terminal-bash/src/sanitize.ts +++ b/packages/terminal/terminal-bash/src/sanitize.ts @@ -2,12 +2,11 @@ import { Buffer } from 'node:buffer' +export { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' + /** OSC marker emitted by the controlled bash before each prompt. */ export const PROMPT_MARKER_PREFIX = '133;D;' -/** Exact printable prompt emitted after the private marker. */ -export const CONTROLLED_PROMPT = 'dsh> ' - /** One sanitized chunk plus whether it contained the owned prompt marker. */ export interface SanitizedChunk { text: string diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index d9e5444465..6f782e3bab 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -42,6 +42,9 @@ export type { } from './types.ts' export { TerminalBackendCleanupError } from './types.ts' +/** Printable prompt shared by terminal backends and persistent shell consumers. */ +export const CONTROLLED_PROMPT = 'dsh> ' + /** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */ export type TerminalSessionId = TerminalSessionIdValue diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1490a0f9f7..2962e3c89d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8340,6 +8340,9 @@ importers: '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../../packages/examples/agent-spine-demo + '@deepseek-ai/dsh-agent-tool-presentation': + specifier: workspace:^ + version: link:../../packages/core/agent-tool-presentation '@deepseek-ai/dsh-anonymous-user-id': specifier: workspace:^ version: link:../../packages/identity/anonymous-user-id @@ -8361,6 +8364,9 @@ importers: '@deepseek-ai/dsh-code-runtime-worker-thread': specifier: workspace:^ version: link:../../packages/code-runtime/code-runtime-worker-thread + '@deepseek-ai/dsh-command-compact': + specifier: workspace:^ + version: link:../../packages/compaction/command-compact '@deepseek-ai/dsh-command-goal': specifier: workspace:^ version: link:../../packages/goal/command-goal @@ -8442,6 +8448,9 @@ importers: '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../packages/interaction/permission-presets + '@deepseek-ai/dsh-persona': + specifier: workspace:^ + version: link:../../packages/preset/persona '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode @@ -8514,6 +8523,9 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:^ version: link:../../packages/skill/skill-filesystem + '@deepseek-ai/dsh-spill': + specifier: workspace:^ + version: link:../../packages/spill/spill '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../packages/subagent/subagent @@ -8568,12 +8580,18 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:^ version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-fs-search': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs-search '@deepseek-ai/dsh-tool-goal': specifier: workspace:^ version: link:../../packages/goal/tool-goal '@deepseek-ai/dsh-tool-jobs': specifier: workspace:^ version: link:../../packages/jobs/tool-jobs + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../packages/workflow/tool-ralph '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index 400d9d585b..ef5a621f9d 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -67,7 +67,7 @@ class RuntimeBuildHook(BuildHookInterface): expected_executable = matches[0][1] runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) - expected_files = [expected_executable] + expected_files = [expected_executable, f"{expected_executable}-rg"] if "-macos-" in expected_executable: expected_files.append(f"{expected_executable}-spawn-helper") found_files = [path.name for path in runtime_files] diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 89d4b81b22..083a8a87d7 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-shell": "workspace:^", @@ -22,6 +23,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^", + "@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", @@ -49,6 +51,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", @@ -71,6 +74,7 @@ "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", + "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-acp": "workspace:^", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", @@ -89,7 +93,9 @@ "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 16af912def..727ee29586 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -5,8 +5,8 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's - **exe (production)**: single-file Node executables named ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in - {x64, arm64}); macOS also uses a sibling ``-spawn-helper``. The target machine - needs no Node installation. + {x64, arm64}) with a sibling ``-rg`` executable; macOS also uses a sibling + ``-spawn-helper``. The target machine needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`` on a @@ -83,6 +83,12 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + _EXE_ACQUISITION_HINT ) + ripgrep = Path(f"{path}-rg") + if not ripgrep.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the ripgrep sidecar at {ripgrep}. " + + _EXE_ACQUISITION_HINT + ) if tag.startswith("macos-"): helper = Path(f"{path}-spawn-helper") if not helper.is_file(): diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 7ff9b15d59..b21a485949 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -92,6 +92,10 @@ def test_stage_runtime_copies_platform_payload( executable.write_bytes(b"runtime") executable.chmod(0o755) expected = {executable.name: b"runtime"} + ripgrep = Path(f"{executable}-rg") + ripgrep.write_bytes(b"ripgrep") + ripgrep.chmod(0o755) + expected[ripgrep.name] = b"ripgrep" if with_helper: spawn_helper = Path(f"{executable}-spawn-helper") spawn_helper.write_bytes(b"helper") diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index da1cea67c4..c104d47ef5 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -396,7 +396,8 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } - if (target.platform !== 'macos') return [product] + const ripgrep = await this.copyRipgrepSidecar(target, product) + if (target.platform !== 'macos') return [product, ripgrep] const spawnHelper = `${product}-spawn-helper` const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') if (this.cli.dryRun) { @@ -405,7 +406,31 @@ class SingleExeBuild { await copyFile(source, spawnHelper) await chmod(spawnHelper, 0o755) } - return [product, spawnHelper] + return [product, ripgrep, spawnHelper] + } + + /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */ + private async copyRipgrepSidecar(target: Target, product: string): Promise { + const platform = target.platform === 'macos' ? 'darwin' : target.platform + const source = join( + this.staging, + 'node_modules', + '@vscode', + `ripgrep-${platform}-${target.arch}`, + 'bin', + 'rg', + ) + const destination = `${product}-rg` + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${source} ${destination}`) + return destination + } + if (!existsSync(source)) { + throw new Error(`build-exe-for-python-sdk: target ripgrep binary is missing at ${source}.`) + } + await copyFile(source, destination) + await chmod(destination, 0o755) + return destination } /** diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 18660b3411..0b4157f40d 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -48,7 +48,8 @@ PLATFORMS = load_platforms() def runtime_suffixes(executable_name: str) -> tuple[str, ...]: - return ("", "-spawn-helper") if "-macos-" in executable_name else ("",) + suffixes = ("", "-rg") + return (*suffixes, "-spawn-helper") if "-macos-" in executable_name else suffixes def main() -> None: diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 700cfb7c79..8eb079fd78 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -29,6 +29,9 @@ MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and stri MINIMAL_TEXT = "minimal agent smoke ok" MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." +FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools." +FS_SEARCH_TEXT = "filesystem search smoke ok" +FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK" MINIMAL_CORDIS = ( Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml" ) @@ -109,6 +112,29 @@ CUSTOM_CORDIS = """\ - id: cordis-tool name: '@deepseek-ai/dsh-tool-cordis' """ +FS_SEARCH_CORDIS = """\ +- id: sdk-jsonrpc-server + name: '@deepseek-ai/dsh-sdk-jsonrpc-server' +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT + compression: 'none' +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' +- id: fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: false +""" class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -143,6 +169,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) + fs_search = fs_search_tool_followup(call_id, tool_name, tool_text) + if fs_search is not None: + return fs_search minimal = minimal_tool_followup(body, call_id, tool_name, tool_text) if minimal is not None: return minimal @@ -192,6 +221,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: SNAPSHOT_PROMPT, CODE_PROMPT, WORKFLOW_PROMPT, + FS_SEARCH_PROMPT, } prompt = next( (candidate for candidate in user_prompts if candidate in scenario_prompts), @@ -233,9 +263,40 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: }, }, ) + if prompt == FS_SEARCH_PROMPT: + assert_advertised_tool(body, "grep") + assert_advertised_tool(body, "glob") + return tool_call_chunks( + "fs-search-grep", + "grep", + {"pattern": FS_SEARCH_MARKER, "path": "."}, + ) return text_chunks(EXPECTED_TEXT) +def fs_search_tool_followup( + call_id: str, + tool_name: str, + tool_text: str, +) -> list[dict[str, object]] | None: + """Exercise both ripgrep-backed tools through the packaged executable.""" + if not call_id.startswith("fs-search-"): + return None + if call_id == "fs-search-grep" and tool_name == "grep": + if "needle.txt" not in tool_text or FS_SEARCH_MARKER not in tool_text: + raise AssertionError(f"packaged grep returned no marker: {tool_text}") + return tool_call_chunks( + "fs-search-glob", + "glob", + {"pattern": "**/*.txt"}, + ) + if call_id == "fs-search-glob" and tool_name == "glob": + if "needle.txt" not in tool_text: + raise AssertionError(f"packaged glob returned no fixture path: {tool_text}") + return text_chunks(FS_SEARCH_TEXT) + raise AssertionError(f"unexpected filesystem-search follow-up: {call_id} {tool_name}: {tool_text}") + + def minimal_tool_followup( body: dict[str, object], call_id: str, @@ -477,13 +538,13 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None: + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"} and args.exe is None: parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: parser.error("--update-snapshots requires --scenario sdk-snapshot or all") @@ -499,6 +560,9 @@ def main() -> None: if args.scenario in {"all", "sdk-minimal"}: assert args.exe is not None smoke_sdk_minimal(model.url, args.exe.resolve()) + if args.scenario in {"all", "sdk-fs-search"}: + assert args.exe is not None + smoke_sdk_fs_search(model.url, args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -588,6 +652,33 @@ def smoke_sdk_minimal(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") +def smoke_sdk_fs_search(base_url: str, executable: Path) -> None: + """Exercise real grep and glob spawns through the packaged executable.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-fs-search-") as temporary: + root = Path(temporary).resolve() + (root / "needle.txt").write_text(f"{FS_SEARCH_MARKER}\n") + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(FS_SEARCH_CORDIS) + with DeepSeekHarness( + provider="deepseek-official", + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + result = harness.run(FS_SEARCH_PROMPT, session_id="fs-search-smoke") + + assert result.final_response == FS_SEARCH_TEXT, result.final_response + assert_session_log(sessions, root, FS_SEARCH_TEXT, FS_SEARCH_MARKER, "needle.txt") + + def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: """Drive and compare the advanced SDK/executable behavioral snapshot.""" from deepseek_harness import DeepSeekHarness diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index c87d562f59..c0015fe11a 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -1,12 +1,18 @@ /** - * Verify that the executable deploy manifest supplies every required workspace - * peer in its dependency graph. With auto peer installation disabled, a missing - * root peer can otherwise fail only when Cordis loads the packaged plugin. + * Verify that the executable deploy manifest supplies every plugin referenced + * by a shipped agent preset and every required workspace peer in its dependency + * graph. With auto peer installation disabled, either omission can otherwise + * fail only when Cordis loads the packaged plugin. */ import { globSync } from 'node:fs' import { readFile } from 'node:fs/promises' -import { resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import { parseArgs } from 'node:util' +import * as yaml from 'js-yaml' + +interface JsExpr { + __jsExpr: string +} interface PackageManifest { name?: string @@ -21,6 +27,23 @@ interface WorkspacePackage { manifest: PackageManifest } +interface RuntimePlatform { + tag: string + executable: string +} + +type RuntimePlatformManifest = Record + +const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + resolve: data => typeof data === 'string', + construct: (data: unknown): JsExpr => { + if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string') + return { __jsExpr: data } + }, +}) +const schema = yaml.JSON_SCHEMA.extend(jsExprType) + const root = resolve(import.meta.dirname, '..') const { values } = parseArgs({ args: process.argv.slice(2), @@ -31,6 +54,7 @@ const runtimeManifest = await loadManifest(runtimeManifestPath) const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime' const workspace = await loadWorkspacePackages() const runtimeDependencies = runtimeManifest.dependencies ?? {} +const platforms = await loadJson(resolve(root, 'python/sdk-runtime/platforms.json')) const parents = new Map() const queue: string[] = [] @@ -40,7 +64,7 @@ for (const dependency of Object.keys(runtimeDependencies).sort()) { queue.push(dependency) } -const failures: string[] = [] +const failures = await missingPresetPlugins(runtimeDependencies, platforms) for (let index = 0; index < queue.length; index += 1) { const packageName = queue[index] if (packageName === undefined) continue @@ -65,12 +89,91 @@ for (let index = 0; index < queue.length; index += 1) { } if (failures.length > 0) { - console.error('verify-runtime-closure: required workspace peers are missing from python/sdk-runtime dependencies:') + console.error('verify-runtime-closure: preset plugins or required workspace peers are missing from python/sdk-runtime dependencies:') for (const failure of failures) console.error(` ${failure}`) process.exit(1) } -console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`) +const presetCount = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length +console.log( + `verify-runtime-closure: ${presetCount} agent presets and ${queue.length} workspace packages form a closed runtime dependency graph.`, +) + +async function missingPresetPlugins( + runtimeDependencies: Readonly>, + platforms: RuntimePlatformManifest, +): Promise { + const missing = new Map>() + const failures: string[] = [] + const presetPaths = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).sort() + for (const presetPath of presetPaths) { + const document: unknown = yaml.load(await readFile(resolve(root, presetPath), 'utf8'), { schema }) + if (!Array.isArray(document)) { + failures.push(`${presetPath}: preset root must be a Loader entry array`) + continue + } + for (const target of Object.keys(platforms).sort()) { + const processPlatform = processPlatformForTarget(target) + for (const plugin of activeBarePluginPackages(document, processPlatform)) { + if (runtimeDependencies[plugin] !== undefined) continue + const preset = basename(dirname(presetPath)) + const key = `${preset} preset -> ${plugin}` + const targets = missing.get(key) ?? new Set() + targets.add(target) + missing.set(key, targets) + } + } + } + failures.push(...[...missing.entries()].map(([chain, targets]) => + `${chain} (${[...targets].sort().join(', ')})`)) + return failures +} + +function activeBarePluginPackages(entries: unknown[], processPlatform: string): Set { + const packages = new Set() + const visit = (value: unknown, parentDisabled: boolean): void => { + if (!isRecord(value)) return + const disabled = parentDisabled || disabledOnPlatform(value.disabled, processPlatform) + if (disabled) return + if (typeof value.name === 'string') { + const packageName = barePackageName(value.name) + if (packageName !== undefined) packages.add(packageName) + } + if (Array.isArray(value.config)) { + for (const child of value.config) visit(child, disabled) + } + } + for (const entry of entries) visit(entry, false) + return packages +} + +function disabledOnPlatform(value: unknown, processPlatform: string): boolean { + if (typeof value === 'boolean') return value + if (!isRecord(value) || typeof value.__jsExpr !== 'string') return false + const match = /^process\.platform\s*(===|!==)\s*(['"])(win32|linux|darwin)\2$/.exec(value.__jsExpr.trim()) + if (match === null) return false + const [, operator, , expected] = match + return operator === '===' ? processPlatform === expected : processPlatform !== expected +} + +function processPlatformForTarget(target: string): string { + if (target.startsWith('linux-')) return 'linux' + if (target.startsWith('macos-')) return 'darwin' + throw new Error(`verify-runtime-closure: unsupported runtime target ${JSON.stringify(target)}`) +} + +function barePackageName(specifier: string): string | undefined { + if (specifier.startsWith('.') || specifier.startsWith('/') || specifier.includes(':')) return undefined + const parts = specifier.split('/') + if (specifier.startsWith('@')) { + return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined + } + return parts[0] || undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} async function loadWorkspacePackages(): Promise> { const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root }) @@ -85,7 +188,11 @@ async function loadWorkspacePackages(): Promise> { } async function loadManifest(path: string): Promise { - return JSON.parse(await readFile(path, 'utf8')) as PackageManifest + return loadJson(path) +} + +async function loadJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as T } function formatChain( From acd8dd43fac57b8faf814d4400cd4ebff6c40735 Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 14 Aug 2026 13:59:49 +0800 Subject: [PATCH 036/137] fix(python-sdk): satisfy runtime packaging gates --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/subsystems/terminal.i18n.yaml | 4 +-- docs/subsystems/terminal.md | 2 +- docs/subsystems/terminal.zh.md | 2 +- .../tool-fs-search/tests/rg-sidecar.spec.ts | 25 +++++++++++++ .../tests/process-inspector.spec.ts | 36 ++++++++++++++++++- python/sdk/tests/test_runtime_resolution.py | 18 +++++++++- scripts/cordis-yaml.ts | 26 ++++++++++++++ scripts/verify-cordis-config.ts | 25 ++----------- scripts/verify-runtime-closure.ts | 18 ++-------- 11 files changed, 114 insertions(+), 46 deletions(-) create mode 100644 packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts create mode 100644 scripts/cordis-yaml.ts diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ef4931f765..e5b36bf91c 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: 82f6d26c79d32c6952f3bc11c96fa1c2ddceecdc +config-catalog.md: 2e4aad7532b061e8328f25a53c2c3b0c4bb4dfa0 config-catalog.zh.md: 958d3115447db37de248bbf30b0744308ff8dbb8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 82f6d26c79..2e4aad7532 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2370,7 +2370,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:406`](../packages/shell/tool-bash-persistent/src/index.ts) diff --git a/docs/subsystems/terminal.i18n.yaml b/docs/subsystems/terminal.i18n.yaml index 859104d415..96f05cc50a 100644 --- a/docs/subsystems/terminal.i18n.yaml +++ b/docs/subsystems/terminal.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/terminal.md -terminal.md: eedf6157b256e83d3b57e07bf738429773b87574 -terminal.zh.md: 3cc2c281e8c2090bb7e92808f932be689fd0b710 +terminal.md: c7031143117a514f8579a22fed07a1461babb15e +terminal.zh.md: 7c469c028e8b3da7c9d5012f9f49c1ae65a9bd03 diff --git a/docs/subsystems/terminal.md b/docs/subsystems/terminal.md index eedf6157b2..c703114311 100644 --- a/docs/subsystems/terminal.md +++ b/docs/subsystems/terminal.md @@ -180,5 +180,5 @@ list(owner: Agent): TerminalSessionSnapshot[] Types: [Agent](core.md) -Source: [`packages/terminal/terminal/src/index.ts:105`](../../packages/terminal/terminal/src/index.ts) +Source: [`packages/terminal/terminal/src/index.ts:108`](../../packages/terminal/terminal/src/index.ts) diff --git a/docs/subsystems/terminal.zh.md b/docs/subsystems/terminal.zh.md index 3cc2c281e8..7c469c028e 100644 --- a/docs/subsystems/terminal.zh.md +++ b/docs/subsystems/terminal.zh.md @@ -180,5 +180,5 @@ list(owner: Agent): TerminalSessionSnapshot[] Types: [Agent](core.md) -Source: [`packages/terminal/terminal/src/index.ts:105`](../../packages/terminal/terminal/src/index.ts) +Source: [`packages/terminal/terminal/src/index.ts:108`](../../packages/terminal/terminal/src/index.ts) diff --git a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts new file mode 100644 index 0000000000..49cca6bfa8 --- /dev/null +++ b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest' + +const existsSync = vi.hoisted(() => vi.fn(() => true)) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, existsSync } +}) + +vi.mock('@vscode/ripgrep', () => new Proxy({}, { + get() { + throw new Error('the platform package must not load when the executable sidecar exists') + }, +})) + +import { resolveRgPath } from '@deepseek-ai/dsh-tool-fs-search' + +describe('single-executable ripgrep resolution', () => { + it('uses the native sidecar beside the current executable', async () => { + const sidecar = `${process.execPath}-rg` + + await expect(resolveRgPath()).resolves.toBe(sidecar) + expect(existsSync).toHaveBeenCalledWith(sidecar) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index 84cae7564a..2402681461 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -140,6 +140,32 @@ describe('Linux process inspector', () => { ]) }) + it('contains cycles in the procfs children index', () => { + const fake = fakeInternals() + fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) + fake.files.set('/proc/10/task/10/children', '11') + fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) + fake.files.set('/proc/11/task/11/children', '10') + + expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ + { pid: 11, started: '501' }, + { pid: 10, started: '500' }, + ]) + }) + + it('falls back to the PID namespace when a descendant children index is unreadable', () => { + const fake = fakeInternals() + fake.dirs.set('/proc', ['10', '11', '12']) + fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) + fake.files.set('/proc/10/task/10/children', '12 invalid 11') + fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) + + expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ + { pid: 11, started: '501' }, + { pid: 10, started: '500' }, + ]) + }) + it('keeps readiness inspection local when procfs has no children index', () => { const fake = fakeInternals() fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) @@ -147,11 +173,19 @@ describe('Linux process inspector', () => { expect(inspector.processTree(10, false)).toEqual([{ pid: 10, started: '500' }]) expect(inspector.isStdinWaiting(10, false)).toBe(false) + + const readFile = fake.internals.readFile.bind(fake.internals) + let statReads = 0 + fake.internals.readFile = (path) => { + if (path === '/proc/10/stat' && statReads++ > 0) throw new Error('process exited') + return readFile(path) + } + expect(inspector.processTree(10, false)).toEqual([]) }) it('detects read, select, poll, and epoll waits across non-leader threads', () => { const fake = fakeInternals() - fake.dirs.set('/proc', ['100', '101']) + fake.dirs.set('/proc', ['77', '100', '101']) fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2')) fake.dirs.set('/proc/100/task', ['100']) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 778203f4d1..14e90f3283 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -51,7 +51,10 @@ def test_runtime_requires_spawn_helper_only_on_macos( runtime_dir.mkdir() linux = runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64" linux.touch() - (runtime_dir / "dsh-jsonrpc-agent-pkg-macos-arm64").touch() + Path(f"{linux}-rg").touch() + macos = runtime_dir / "dsh-jsonrpc-agent-pkg-macos-arm64" + macos.touch() + Path(f"{macos}-rg").touch() monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "macos-arm64") @@ -59,3 +62,16 @@ def test_runtime_requires_spawn_helper_only_on_macos( runtime.bundled_runtime_path() monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") assert runtime.bundled_runtime_path() == linux + + +def test_runtime_requires_ripgrep_sidecar( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + (runtime_dir / "dsh-jsonrpc-agent-pkg-linux-x64").touch() + monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") + + with pytest.raises(FileNotFoundError, match="ripgrep sidecar"): + runtime.bundled_runtime_path() diff --git a/scripts/cordis-yaml.ts b/scripts/cordis-yaml.ts new file mode 100644 index 0000000000..60cd492787 --- /dev/null +++ b/scripts/cordis-yaml.ts @@ -0,0 +1,26 @@ +import * as yaml from 'js-yaml' + +export interface JsExpr { + __jsExpr: string +} + +const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { + kind: 'scalar', + resolve: data => typeof data === 'string', + construct: (data: unknown): JsExpr => { + if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string') + return { __jsExpr: data } + }, +}) +const schema = yaml.JSON_SCHEMA.extend(jsExprType) + +/** Parse a Cordis config while preserving Loader `!!js` expressions as data. */ +export function loadCordisYaml(source: string): unknown { + return yaml.load(source, { schema }) +} + +export function isJsExpr(value: unknown): value is JsExpr { + return typeof value === 'object' + && value !== null + && typeof (value as Record).__jsExpr === 'string' +} diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index f2e0aef46c..9a94a281e8 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -12,13 +12,9 @@ import { globSync, readFileSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' -import * as yaml from 'js-yaml' import ts from 'typescript' import { cordisConfigFiles } from './cordis-config-files.ts' - -interface JsExpr { - __jsExpr: string -} +import { isJsExpr, loadCordisYaml } from './cordis-yaml.ts' interface PackageManifest { name?: string @@ -56,16 +52,6 @@ const CHOOSER_BACKEND_PACKAGES = [ '@deepseek-ai/dsh-client-ui-directory-picker-browse', '@deepseek-ai/dsh-client-ui-directory-picker-native', ] -const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { - kind: 'scalar', - resolve: data => typeof data === 'string', - construct: (data: unknown): JsExpr => { - if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string') - return { __jsExpr: data } - }, -}) -const schema = yaml.JSON_SCHEMA.extend(jsExprType) - const errors: string[] = [] const pluginReferences: PluginReference[] = [] @@ -73,7 +59,7 @@ if (import.meta.main) { const files = cordisConfigFiles(root) for (const file of files) { - const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) + const document = loadCordisYaml(readFileSync(resolve(root, file), 'utf8')) if (!isUnknownArray(document)) { errors.push(`${file}: root must be a Loader entry array`) continue @@ -172,7 +158,7 @@ function validatePresetPlaneSeparation(): string[] { /** Every entry of one config file, or an empty list when it is not an entry array. */ function loadEntries(file: string): unknown[] { - const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema }) + const document = loadCordisYaml(readFileSync(resolve(root, file), 'utf8')) return isUnknownArray(document) ? document : [] } @@ -462,7 +448,6 @@ export function metadataExpressionErrors(entry: Record, path: s function disabledExpressionProblem(expression: string): string | undefined { try { // Compilation only — the constructor never executes the body. - // oxlint-disable-next-line typescript/no-implied-eval new Function(`return (${expression})`) return undefined } catch (error) { @@ -484,10 +469,6 @@ function collectExpressionPaths(value: unknown, path: string, output: string[]): for (const [key, child] of Object.entries(value)) collectExpressionPaths(child, `${path}.${key}`, output) } -function isJsExpr(value: unknown): value is JsExpr { - return isRecord(value) && typeof value.__jsExpr === 'string' -} - function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' } diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index c0015fe11a..1287542dc2 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -8,11 +8,7 @@ import { globSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' import { parseArgs } from 'node:util' -import * as yaml from 'js-yaml' - -interface JsExpr { - __jsExpr: string -} +import { loadCordisYaml } from './cordis-yaml.ts' interface PackageManifest { name?: string @@ -34,16 +30,6 @@ interface RuntimePlatform { type RuntimePlatformManifest = Record -const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { - kind: 'scalar', - resolve: data => typeof data === 'string', - construct: (data: unknown): JsExpr => { - if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string') - return { __jsExpr: data } - }, -}) -const schema = yaml.JSON_SCHEMA.extend(jsExprType) - const root = resolve(import.meta.dirname, '..') const { values } = parseArgs({ args: process.argv.slice(2), @@ -107,7 +93,7 @@ async function missingPresetPlugins( const failures: string[] = [] const presetPaths = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).sort() for (const presetPath of presetPaths) { - const document: unknown = yaml.load(await readFile(resolve(root, presetPath), 'utf8'), { schema }) + const document = loadCordisYaml(await readFile(resolve(root, presetPath), 'utf8')) if (!Array.isArray(document)) { failures.push(`${presetPath}: preset root must be a Loader entry array`) continue From a6d7ac7438acb2482b6915ce1dc79e2825e8856a Mon Sep 17 00:00:00 2001 From: fz Date: Fri, 14 Aug 2026 14:12:23 +0800 Subject: [PATCH 037/137] fix(ci): avoid implied eval in config verification --- scripts/verify-cordis-config.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 9a94a281e8..9777979f5f 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -12,6 +12,7 @@ import { globSync, readFileSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' +import { Script } from 'node:vm' import ts from 'typescript' import { cordisConfigFiles } from './cordis-config-files.ts' import { isJsExpr, loadCordisYaml } from './cordis-yaml.ts' @@ -447,8 +448,8 @@ export function metadataExpressionErrors(entry: Record, path: s */ function disabledExpressionProblem(expression: string): string | undefined { try { - // Compilation only — the constructor never executes the body. - new Function(`return (${expression})`) + // Compilation only — constructing a Script does not execute its source. + new Script(`(${expression})`) return undefined } catch (error) { const detail = error instanceof Error ? error.message : String(error) From ebcf7d042333e34a8212dbcfc7346af4abf25479 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 14 Aug 2026 16:25:41 +0800 Subject: [PATCH 038/137] fix: preserve product identifiers across rescope --- scripts/rescope-vendor.ts | 29 +++++++++++++++++++++++++++++ tsconfig.base.json | 1 + 2 files changed, 30 insertions(+) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 3f5cb525c2..44b86c7e66 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -104,6 +104,35 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ // GROUP_ORDER holds `packages//` directory names, not package names. { file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] }, { file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] }, + // `cordis/*` is the extensions event domain, not a package subpath. The + // generated catalogs and every producer/consumer must preserve that wire id. + { file: 'docs/event-producer-consumer.md', upstream: ['cordis'] }, + { file: 'docs/event-producer-consumer.zh.md', upstream: ['cordis'] }, + { file: 'docs/subsystems/extensions.md', upstream: ['cordis'] }, + { file: 'docs/subsystems/extensions.zh.md', upstream: ['cordis'] }, + { file: 'packages/api/remotes/src/remote-events.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-client-runner/src/client/index.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-client-runner/src/client/runtime.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-client-runner/tests/orchestrator.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/src/index.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/src/inspect-registry.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/src/types.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/tests/helpers.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/tests/runner.spec.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/cordis-host-runner/tests/versioning.spec.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/tool-cordis/src/api-catalog.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/tool-cordis/src/providers.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/index.ts', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/inventory.ts', upstream: ['cordis'] }, + { file: 'scripts/gen-cordis-catalog.ts', upstream: ['cordis'] }, + // The UI locale namespace and input-trigger source id are product keys. + { file: 'packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/CordisActionRow.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/CordisPanel.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/CordisRunRow.tsx', upstream: ['cordis'] }, + { file: 'packages/extensions/ui-cordis/src/client/locales.ts', upstream: ['cordis'] }, ] /** A string that must appear exactly `count` times once the rescope has run. */ diff --git a/tsconfig.base.json b/tsconfig.base.json index 16d69db6db..ba80a3286f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -199,6 +199,7 @@ "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"], "@deepseek-ai/dsh-client-ui-input-trigger": ["./packages/client/ui-input-trigger/src"], + "@deepseek-ai/dsh-client-ui-reference": ["./packages/client/ui-reference/src"], "@deepseek-ai/dsh-client-ui-commands": ["./packages/client/ui-commands/src"], "@deepseek-ai/dsh-client-ui-model-selection": ["./packages/client/ui-model-selection/src"], "@deepseek-ai/dsh-client-ui-goal": ["./packages/client/ui-goal/src"], From a4e2e1e6e987382a2038471981e86d3ef93c1b6d Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 11:17:35 +0800 Subject: [PATCH 039/137] fix(gates): align tool-pwsh-persistent version and publish access with the rc.6 release --- packages/shell/tool-pwsh-persistent/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 4f2fc37146..c353f19bcd 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,9 +1,9 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.0.1-rc.2", + "version": "0.1.0-rc.6", "publishConfig": { - "access": "restricted" + "access": "public" }, "repository": { "type": "git", From f61e884917fac88c2f4d67588aa1382b6337c1f9 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 11:25:23 +0800 Subject: [PATCH 040/137] fix(gates): declare the MIT license for tool-pwsh-persistent --- packages/shell/tool-pwsh-persistent/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index c353f19bcd..84278ef05e 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -29,7 +29,7 @@ "lib/invariant.js", "lib/types/**/*.d.ts" ], - "license": "BSD-3-Clause", + "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", From 06b766711c9c7e8acd9ecb1b0b841f33d16a6114 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 11:45:13 +0800 Subject: [PATCH 041/137] fix(pty): pin UTF-8 output encodings in the persistent pwsh bootstrap --- .../terminal/terminal-bash/README.i18n.yaml | 4 +-- packages/terminal/terminal-bash/README.md | 4 +-- packages/terminal/terminal-bash/README.zh.md | 4 +-- packages/terminal/terminal-bash/src/index.ts | 14 ++++++---- .../terminal-bash/tests/index.spec.ts | 3 ++- .../terminal-bash/tests/local.spec.ts | 27 +++++++++++++++++++ 6 files changed, 44 insertions(+), 12 deletions(-) diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index 67afd7593c..2f648e4111 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/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/terminal/terminal-bash/README.md -README.md: ac8c4c8daf6db255bc55af3af49367f1de2c9a8a -README.zh.md: d10c7560173415281ad2820c5cb17ba3a7d4bf62 +README.md: 8b8c8293d7f2fbdc50b2311b1f327eb509578b72 +README.zh.md: 89f9323abc433ae9d212eb0b48ecd47760c635bc diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index ac8c4c8daf..8b8c8293d7 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -8,7 +8,7 @@ Persistent shell backend for `ctx.terminals` over `ctx.subprocess.spawnTerminal` The plugin injects `pty`, `sandboxPolicy`, and `subprocess`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly without requiring a sandbox provider; confined modes require a same-world `ctx.sandbox` and wrap the exact shell argv through it, failing before spawn when none is mounted. At spawn, one `ctx.sandboxPolicy.resolve({ session })` call supplies both the effective mode and the session workspace root; the same root is the default shell cwd when the caller omits one. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. +`shellDialect` selects the shell stack (`bash` default, `pwsh`): it picks the default `shellPath`/`shellArgs` (bash `--noprofile --norc -i`; pwsh `-NoLogo -NoProfile` through the shared `dsh-pwsh-local` resolver) and the startup contract. The bash dialect installs its prompt through the environment (`PS1` plus an OSC `133;D;`-terminated `PROMPT_COMMAND`). pwsh cannot install a prompt from the environment, so the backend writes a `prompt` function through the session and waits until the controlled prompt is actually visible — looping over follow-up sends because the pwsh banner-to-prompt gap can outlast the silence bound — while its environment drops the bash-only markers and adds `NO_COLOR`. That first send also prefixes the shared `dsh-pwsh-local` encoding preamble, pinning `[Console]::OutputEncoding` and `$OutputEncoding` to UTF-8 before anything runs: the session decode path reads PTY bytes as UTF-8, and an un-pinned console writes its host code page for non-ASCII output. Both dialects emit the same BEL-terminated OSC marker, so the readiness machinery and consumers are dialect-agnostic. Readiness combines a foreground-verified private bash prompt marker, provider-reported foreground stdin-wait facts, silence fallback, and absolute timeout. A marker is not ready until the printable tail after the latest owned marker exactly equals the controlled `PS1`, including when the OSC marker and prompt are split across data callbacks; echoed input or output following an earlier prompt therefore cannot settle the current send. Prompt and silence evidence collected before the provider write, including while pre-write foreground inspection is pending, is discarded at the write boundary. When bash prints the marker before the terminal provider publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unknown foreground state is never a positive exact-idle signal. A foreground group's stdin wait that existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason; `TerminalBackendCleanupError` separately preserves a cleanup failure. The caller's signal is forwarded for terminal allocation and readiness initialization; after publication the handle owns its lifetime. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; malformed UTF-8 terminal output uses replacement characters, and a trailing carriage return is carried across callbacks so split CRLF becomes one newline. @@ -34,6 +34,6 @@ A standing-policy change appends an owner-rendered superseding runtime-context s - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. - Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. -- The pwsh `prompt` bootstrap writes through `[Console]::`, which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. +- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index d10c756017..89f9323abc 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -8,7 +8,7 @@ 该插件注入 `pty`、`sandboxPolicy` 和 `subprocess`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 无需沙箱提供方即可直接启动 shell;受限模式要求同一执行世界中存在 `ctx.sandbox`,并通过它包装确切的 shell argv,未挂载时会在 spawn 前失败。spawn 时,一次 `ctx.sandboxPolicy.resolve({ session })` 调用会同时给出实际模式与会话工作区根目录;调用方省略 cwd 时,同一根目录也是 shell 的默认 cwd。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建完成并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 -`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 +`shellDialect` 选择 shell 栈(默认 `bash`,或 `pwsh`):它决定默认的 `shellPath`/`shellArgs`(bash 为 `--noprofile --norc -i`;pwsh 经共享的 `dsh-pwsh-local` 解析器得到 `-NoLogo -NoProfile`)与启动契约。bash 方言通过环境安装提示符(`PS1` 加 OSC `133;D;` 终结的 `PROMPT_COMMAND`)。pwsh 无法从环境安装提示符,因此后端通过会话写入 `prompt` 函数,并等待受控提示符真正可见——因为 pwsh 从横幅到提示符的间隙可能超过静默上限,所以会在后续 send 上循环等待;同时其环境去掉 bash 专属标记并加 `NO_COLOR`。同一条首发送还会带上共享的 `dsh-pwsh-local` 编码前缀,在一切运行之前把 `[Console]::OutputEncoding` 与 `$OutputEncoding` 钉为 UTF-8:会话解码路径按 UTF-8 读取 PTY 字节,未钉住编码的控制台会以宿主代码页输出非 ASCII 内容。两种方言发出相同的 BEL 终结 OSC 标记,因此就绪机制与消费方与方言无关。 就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、提供方报告的前台 stdin 等待事实、静默回退和绝对超时。只有最新自有标记之后的可打印尾部与受控 `PS1` 完全相等,标记才算就绪;即使 OSC 标记和提示符被拆到多个数据回调中也一样。因此,较早提示符之后的回显输入或输出无法使当前 send 完成。提供方写入前收集的提示符与静默证据,包括写入前前台检查仍在等待时收集的证据,都会在写入边界丢弃。如果 bash 在终端提供方发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法一直抑制推断空闲就绪直至绝对超时。未知的前台状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝;`TerminalBackendCleanupError` 会单独保留清理失败。调用方的 signal 会转发给终端分配与就绪初始化;发布后,句柄负责其生命周期。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。格式错误的 UTF-8 终端输出使用替换字符;末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 @@ -34,6 +34,6 @@ - 输出按行规范化;不支持全屏备用缓冲区交互。 - 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 -- pwsh `prompt` 引导通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 +- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 8e207eeb8e..012a57aae0 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -12,6 +12,7 @@ import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local' import { type Config, type ResolvedConfig, resolveConfig, type ShellDialect, validateConfig } from './config.ts' import { LocalPtySession } from './session.ts' import { CONTROLLED_PROMPT } from './sanitize.ts' @@ -112,15 +113,18 @@ async function startupSession( // pwsh cannot install its prompt from the environment: write the prompt // function through the session and wait for the first marker prompt, // which is also the readiness contract of the bash initialize path. The - // banner-to-prompt gap can outlast the silence bound, so the wait loops - // over follow-up sends until the controlled prompt is actually visible - // (in the viewport or the retained scrollback when it landed between - // sends), bounded by the send deadline. + // first send also pins UTF-8 output (the shared pwsh-local preamble) + // before anything runs: the session decode path treats PTY bytes as + // UTF-8, and an un-pinned console writes its host code page for + // non-ASCII output. The banner-to-prompt gap can outlast the silence + // bound, so the wait loops over follow-up sends until the controlled + // prompt is actually visible (in the viewport or the retained scrollback + // when it landed between sends), bounded by the send deadline. let viewport = '' for (;;) { const first = viewport.length === 0 const operation = session.startSend({ - text: first ? PWSH_PROMPT_SETUP : '', + text: first ? ENCODING_PREAMBLE + PWSH_PROMPT_SETUP : '', submit: first, ...signal !== undefined ? { signal } : {}, }) diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index c1efd399d8..0445f1b6d2 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -11,6 +11,7 @@ import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-p import TerminalSessionService, { TerminalBackendCleanupError, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import type { TerminalSendRequest, TerminalWaitReason } from '@deepseek-ai/dsh-terminal' import { BashTerminalBackend, PWSH_PROMPT_SETUP } from '@deepseek-ai/dsh-terminal-bash' +import { ENCODING_PREAMBLE } from '@deepseek-ai/dsh-pwsh-local' import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash' import type { ResolvedConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts' import type { LocalPtySession } from '@deepseek-ai/dsh-terminal-bash/src/session.ts' @@ -368,7 +369,7 @@ describe('BashTerminalBackend startup rollback', () => { () => session, ) expect(await backend.spawn(spec(agent(ctx)))).toBe(session) - expect(sent).toMatchObject({ text: PWSH_PROMPT_SETUP, submit: true }) + expect(sent).toMatchObject({ text: ENCODING_PREAMBLE + PWSH_PROMPT_SETUP, submit: true }) expect(session.motd).toBe('setup-echo dsh> ') expect(spawned?.env).toMatchObject({ TERM: 'dumb', NO_COLOR: '1', DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1', diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 232ef7d984..ac5ad5be58 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -294,4 +294,31 @@ describe.skipIf(!hasPwsh)('terminal-bash pwsh real shell', () => { else process.env.DSH_TEST_SECRET = previous } }, 30_000) + + it('pins UTF-8 output encoding so non-ASCII output survives the byte decode', async () => { + const { ctx, root, agent } = await harness('danger-full-access', { + idleSilenceMs: 300, + handoffGraceMs: 300, + timeoutMs: 8_000, + }, 'pwsh') + const created = await ctx.terminals.spawn(agent, { type: 'shell', name: 'main', cwd: root }) + // The bootstrap itself must have pinned both encodings: the session byte + // decode is UTF-8, so an un-pinned console writing its host code page + // garbles every non-ASCII byte that follows. + const pinned = ctx.terminals.startSend(agent, created.sessionId, { + text: '"console=" + [Console]::OutputEncoding.WebName + " out=" + $OutputEncoding.WebName', + submit: true, + }) + const pinnedResult = await pinned.done + expect(pinnedResult.viewport).toContain('console=utf-8 out=utf-8') + // Char codes keep the submitted line ASCII-only, so the assertion is a + // pure output-decode check. + const sent = ctx.terminals.startSend(agent, created.sessionId, { + text: "[Console]::Write([char]0x4E2D + [char]0x6587 + ' encoding-ok')", + submit: true, + }) + const result = await sent.done + expect(result.viewport).toContain('中文 encoding-ok') + await ctx.terminals.kill(agent, created.sessionId) + }, 30_000) }) From c854749c34dc88a77e757b9d6e58d8694e594404 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 17:05:19 +0800 Subject: [PATCH 042/137] fix(pty): ship dsh-pwsh-local in the python runtime closure --- pnpm-lock.yaml | 3 +++ python/sdk-runtime/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 474669d616..5aee0f646f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8509,6 +8509,9 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-pwsh-local': + specifier: workspace:^ + version: link:../../packages/shell/pwsh-local '@deepseek-ai/dsh-repeat-tool-reminder': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-reminder diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 89d4b81b22..116a36b35e 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -49,6 +49,7 @@ "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", From 99151657c0bf580a76d164ecfddbd094dd1355c3 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 15 Aug 2026 20:49:15 +0800 Subject: [PATCH 043/137] fix(pty): import resolvePwshPath from the pwsh-local package root --- packages/terminal/terminal-bash/src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/terminal/terminal-bash/src/config.ts b/packages/terminal/terminal-bash/src/config.ts index 19fada0c43..848fd8bf9a 100644 --- a/packages/terminal/terminal-bash/src/config.ts +++ b/packages/terminal/terminal-bash/src/config.ts @@ -1,7 +1,7 @@ /** Validated configuration for the local PTY backend. */ import z from '@deepseek-ai/schemastery' -import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local/src/resolve.ts' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' /** One supported interactive shell dialect. */ export type ShellDialect = 'bash' | 'pwsh' From 6bb79911ba1e666a2d0f0e285249923b87f1bd38 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 18:35:04 +0800 Subject: [PATCH 044/137] refactor(reference): serve discovery through typert Remote faces Replace the legacy reference.* API Proxy domain with @Remote methods on the owning services, following the typert gateway design master adopted on 2026-08-02 (message-feedback and plugin-inventory precedents): - FileReferenceService and SessionReferenceResolver extend TypertRemoteService; fileReferences/list and sessionReferenceResolver/candidates are unary Remote methods cancelled through the reserved trailing signal, and the candidates face attaches each candidate's canonical mention under the configured limit - move the wire types to type-only ./types subpaths (FileReferenceCandidate, SessionReferenceMentionCandidate) and export ./typert plus ./remote artifacts - mount both contributions in the api-remotes client assembly; ui-reference consumes ctx.remote instead of connection.api.references and registers zh/en locale dictionaries for its sections and labels - delete the reference.* routes, schemas, map rows, client stubs, and fixtures; the connection fixture serves the Remote endpoints instead - release deliverPrompt admission listeners when the agent is disposed with the prepared prompt still pending, and cover the reference-* RpcError codes in the schema spec - add the missing tsconfig paths for the /grammar and /types subpaths (clean- tree vitest could not resolve @deepseek-ai/dsh-file-reference/grammar) - regenerate the cordis catalog, capability seams, and event matrix; update the owning bilingual READMEs, Agent Notes, and the reference-composer golden --- ...6-07-21-cross-session-references.i18n.yaml | 4 +- .../2026-07-21-cross-session-references.md | 2 +- .../2026-07-21-cross-session-references.zh.md | 2 +- ...-web-file-and-session-references.i18n.yaml | 4 +- ...6-07-27-web-file-and-session-references.md | 14 +- ...7-27-web-file-and-session-references.zh.md | 14 +- apps/web/tests/reference-composer.e2e.ts | 4 +- .../reference-composer/menu.expected.md | 4 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 3 +- docs/capability-seams.zh.md | 3 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/session-reference.i18n.yaml | 4 +- docs/subsystems/session-reference.md | 37 ++- docs/subsystems/session-reference.zh.md | 37 ++- docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 2 +- docs/subsystems/typert.zh.md | 2 +- knip.json | 10 + packages/api/remotes/package.json | 20 +- packages/api/remotes/src/client/index.ts | 12 +- packages/api/remotes/tsconfig.client.json | 6 + 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/fixture.ts | 87 ++++--- .../client/connection/src/client/index.ts | 1 - .../connection/tests/fake-api.client.ts | 5 - .../client/runtime/tests/fake-api.client.ts | 5 - packages/client/ui-reference/README.i18n.yaml | 4 +- packages/client/ui-reference/README.md | 4 +- packages/client/ui-reference/README.zh.md | 4 +- packages/client/ui-reference/package.json | 15 +- .../client/ui-reference/src/client/index.ts | 72 ++++-- .../client/ui-reference/src/client/locales.ts | 34 +++ .../tests/browser-plugin.client.spec.ts | 235 ++++++++---------- packages/client/ui-reference/tsconfig.json | 22 +- packages/context/README.i18n.yaml | 4 +- packages/context/README.md | 4 +- packages/context/README.zh.md | 4 +- .../context/file-reference/README.i18n.yaml | 4 +- packages/context/file-reference/README.md | 2 +- packages/context/file-reference/README.zh.md | 2 +- packages/context/file-reference/package.json | 23 +- .../context/file-reference/src/grammar.ts | 2 +- packages/context/file-reference/src/index.ts | 32 ++- packages/context/file-reference/src/types.ts | 13 + .../file-reference/tests/service.spec.ts | 21 ++ .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 2 +- .../context/session-reference/README.zh.md | 2 +- .../context/session-reference/package.json | 24 +- .../context/session-reference/src/index.ts | 33 ++- .../context/session-reference/src/types.ts | 18 +- .../tests/session-reference.spec.ts | 18 ++ .../extensions/tool-cordis/src/api-catalog.ts | 16 ++ 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 | 4 +- packages/host/apiproxy/src/api-proxy.ts | 88 +------ packages/host/apiproxy/src/api/index.ts | 3 - .../apiproxy/src/api/references.schema.ts | 42 ---- packages/host/apiproxy/src/api/references.ts | 39 --- packages/host/apiproxy/src/api/rpc-map.ts | 3 - packages/host/apiproxy/src/fetch/client.ts | 15 -- packages/host/apiproxy/src/fetch/handler.ts | 9 - packages/host/apiproxy/src/index.ts | 2 - .../tests/api-proxy-references.spec.ts | 105 +++----- .../apiproxy/tests/client-handler.spec.ts | 6 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 8 - .../host/apiproxy/tests/rpc-schemas.spec.ts | 5 + packages/host/apiproxy/tsconfig.json | 3 - pnpm-lock.yaml | 34 ++- scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 3 +- scripts/type-equiv.manifest.json | 7 +- tsconfig.base.json | 3 + 81 files changed, 715 insertions(+), 596 deletions(-) create mode 100644 packages/client/ui-reference/src/client/locales.ts create mode 100644 packages/context/file-reference/src/types.ts create mode 100644 packages/context/file-reference/tests/service.spec.ts delete mode 100644 packages/host/apiproxy/src/api/references.schema.ts delete mode 100644 packages/host/apiproxy/src/api/references.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index b07c28f618..0cc877effb 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.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-21-cross-session-references.md -2026-07-21-cross-session-references.md: eaa8191a0e71edfc4ea056788e24f513e495cdce -2026-07-21-cross-session-references.zh.md: f9af2960d7905c91ea1cbdbb982ce7c0bf987aa2 +2026-07-21-cross-session-references.md: d9aaaca442ba939b4221351e0df3601294674587 +2026-07-21-cross-session-references.zh.md: 17c3f91225f5f5ae2c2b1a639cc9d3c1794a490b diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index eaa8191a0e..d9aaaca442 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -34,7 +34,7 @@ Reference preparation is not a new steering protocol and does not create a turn The unified Web `@` source combines session candidates with Host-backed file discovery. Session candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the request's cancellation signal, and session id, cwd, and mention labels escape external control characters while the canonical URI retains the original id. -Web exposes discovery and preparation through `reference.sessions` and `session.prompt`, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. The composer retains text and chips until preparation and enqueue succeed, restoring them unchanged after failure; replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON. +Web exposes discovery through the `sessionReferenceResolver/candidates` Remote method and preparation through `session.prompt`, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. The composer retains text and chips until preparation and enqueue succeed, restoring them unchanged after failure; replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index f9af2960d7..17c3f91225 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -34,7 +34,7 @@ Web 负责快照/直接消息事务,不扩展通用收件箱记录。交付 统一的 Web `@` source 把会话候选与 Host 支持的文件发现组合在一起。会话候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。查询遵循请求的取消信号;session id、cwd 和提及标签中的外部控制字符会被转义,但规范 URI 仍保留原始 id。 -Web 通过 `reference.sessions` 和 `session.prompt` 暴露发现与准备流程,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范提及标记支撑的原子 chip。composer 会保留文本和 chip,直到准备与入队均成功;失败后会原样恢复。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 +Web 通过 `sessionReferenceResolver/candidates` Remote 方法暴露发现流程、通过 `session.prompt` 暴露准备流程,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范提及标记支撑的原子 chip。composer 会保留文本和 chip,直到准备与入队均成功;失败后会原样恢复。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml index 9016d377fb..fcd057ebd7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.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-27-web-file-and-session-references.md -2026-07-27-web-file-and-session-references.md: dca1b6eb63eb96f54da85e8524f98018e7496f4b -2026-07-27-web-file-and-session-references.zh.md: 7c87a0aa54b4b528cb81967b818e815c8812ba8d +2026-07-27-web-file-and-session-references.md: 5032525d6362b1afa0da387c2dd3d5f2d961139f +2026-07-27-web-file-and-session-references.zh.md: fce6ec0d35b7712d2e966932144b468f88b67862 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md index dca1b6eb63..5032525d63 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -10,18 +10,18 @@ The Web composer had a reusable slash/reference trigger pipeline, but its `@` so ## Decision -Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Host lookups concurrently and deterministically orders files before sessions with stable labels; non-selectable `文件与文件夹` and `Session 对话` headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other. +Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-client-ui-reference`. For each unquoted query it starts both Remote discovery calls concurrently and deterministically orders files before sessions with locale-registered labels; non-selectable file and session section headings distinguish the two contiguous candidate sections without entering the keyboard-selection index. An open quoted token searches files only. Either candidate domain may fail independently without hiding successful rows from the other. -The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the Host RPC and shared grammar. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash. +The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash. -A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferences.prepare()` before delivery. Queued delivery binds the prepared context to the exact message id through a one-shot `agent/prompt-submit` wrapper and attaches it only to an allowed decision; steering delivery calls `inject()` immediately before `steer()`. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure deliver nothing. +A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferenceResolver.prepare()` before delivery. Delivery binds the prepared context to the exact message id through a one-shot outer `agent/pre-step` listener that inserts the frozen snapshot immediately before that message on an enter decision; ordinary discard or agent disposal releases the listener, and queue-to-steer relocation preserves the pairing. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure deliver nothing. -The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or RPC failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders adjacent metadata-confirmed session references as separate reference chips and preserves that projection when following text is adjacent without whitespace, plus a compact session-source summary instead of the snapshot JSON baked into model content. +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or Remote failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders adjacent metadata-confirmed session references as separate reference chips and preserves that projection when following text is adjacent without whitespace, plus a compact session-source summary instead of the snapshot JSON baked into model content. ## Reference transaction ```text -type @ → parallel file/session RPCs → pick path text or canonical session chip +type @ → parallel file/session Remote calls → pick path text or canonical session chip → serialize draft → Host parses and prepares all sessions → enqueue once ↘ any pre-enqueue failure: retain the unchanged editable draft ``` @@ -42,8 +42,8 @@ File lookup is advisory and cancellable; selection itself performs no read. Sess ## Verification -Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, Host wire validation, all-or-nothing prompt preparation, and draft retention across serialization and RPC failures. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition. +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, the owning services' Remote faces, all-or-nothing prompt preparation, and draft retention across serialization and Remote failures. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition. ## Consequences -Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. The new file-reference seam adds two packages and one Host RPC domain, but keeps browser bundles free of Node APIs and permits another provider to align completion with a remote filesystem. Candidate lookup failures remain quiet menu degradation; submission failures remain explicit and recoverable. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. +Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. The new file-reference seam adds two packages whose discovery methods are unary Remote contracts on the owning services, keeps browser bundles free of Node APIs, and permits another provider to align completion with a remote filesystem. Candidate lookup failures remain quiet menu degradation; submission failures remain explicit and recoverable. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md index 7c87a0aa54..fce6ec0d35 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -10,18 +10,18 @@ Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 ## 决策 -Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项宿主查询,以确定性顺序把文件排在会话之前,并使用稳定标签;不可选择的 `文件与文件夹` 和 `Session 对话` 标题会区分两个连续的候选分组,且不会进入键盘选择索引。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。 +Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 与 `@session` 菜单。每次处理未加引号的查询时,它会并发启动两项 Remote 发现调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的标签;不可选择的文件与会话分组标题会区分两个连续的候选分组,且不会进入键盘选择索引。尚未闭合的带引号 token 只搜索文件。任一候选领域都可以独立失败,不会隐藏另一领域成功返回的行。 -文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费宿主 RPC 与共享语法。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。 +文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。 -选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在递送前调用 `ctx.sessionReferences.prepare()`。队列递送通过一次性 `agent/prompt-submit` 包装层把准备后的上下文绑定到精确消息 id,并且只为获准决策附加该上下文;steering 递送会紧邻 `steer()` 之前调用 `inject()`。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会递送消息。 +选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在递送前调用 `ctx.sessionReferenceResolver.prepare()`。递送通过一次性的外层 `agent/pre-step` 监听器把准备后的上下文绑定到精确消息 id:进入决策时把冻结快照插入到该消息紧前,普通丢弃或 agent(智能体)销毁会释放监听器,队列转 steering 的迁移保持这一配对。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会递送消息。 -输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 RPC 失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把相邻且经元数据确认的会话引用分别渲染为独立的引用 chip,即使后续文本无空白紧邻,也会保持该投影;同时显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 Remote 调用失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把相邻且经元数据确认的会话引用分别渲染为独立的引用 chip,即使后续文本无空白紧邻,也会保持该投影;同时显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 ## 引用事务 ```text -type @ → parallel file/session RPCs → pick path text or canonical session chip +type @ → parallel file/session Remote calls → pick path text or canonical session chip → serialize draft → Host parses and prepares all sessions → enqueue once ↘ any pre-enqueue failure: retain the unchanged editable draft ``` @@ -42,8 +42,8 @@ type @ → parallel file/session RPCs → pick path text or canonical session ch ## 验证 -包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、宿主协议校验、全有或全无的提示词准备,以及在序列化和 RPC 失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、所属服务的 Remote 调用面、全有或全无的提示词准备,以及在序列化和 Remote 调用失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 ## 后果 -Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。新的文件引用 seam 增加了两个包和一个宿主 RPC 领域,但浏览器 bundle 中不包含 Node API,并允许其他提供方让补全与远程文件系统对齐。候选查询失败仍会让菜单静默降级;提交失败仍会显式报告且可恢复。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 +Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。新的文件引用 seam 增加了两个包,其发现方法是所属服务上的一元 Remote 契约;浏览器 bundle 中不包含 Node API,并允许其他提供方让补全与远程文件系统对齐。候选查询失败仍会让菜单静默降级;提交失败仍会显式报告且可恢复。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 8a1da34157..c67a975cbd 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -92,8 +92,8 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await expect.poll(() => menu.getByRole('option').count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2) const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) - expect(snapshot).toContain('\u6587\u4ef6\u4e0e\u6587\u4ef6\u5939') - expect(snapshot).toContain('Session \u5bf9\u8bdd') + expect(snapshot).toContain('Files & folders') + expect(snapshot).toContain('Session conversations') expect(snapshot).toContain('File \u00b7 reference.txt') expect(snapshot).toContain('Session \u00b7 Research notes') expect(snapshot).not.toContain('text: Subagents') diff --git a/apps/web/tests/snapshots/reference-composer/menu.expected.md b/apps/web/tests/snapshots/reference-composer/menu.expected.md index 1b2a95f426..f42be2dc47 100644 --- a/apps/web/tests/snapshots/reference-composer/menu.expected.md +++ b/apps/web/tests/snapshots/reference-composer/menu.expected.md @@ -1,5 +1,5 @@ - listbox "Trigger suggestions": - - text: reference 文件与文件夹 + - text: reference Files & folders - option "File · reference.txt reference.txt" [selected] - - text: Session 对话 + - text: Session conversations - option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}" diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index bcc81a9737..da9ced92e9 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: dddbb3822af6b0863c7f181e34f8eaa9ec68d87f -capability-seams.zh.md: 893d034b610ce9568ca88aa49b94e087fcff005d +capability-seams.md: 134f95f0efacafaf70017d61a8a1d468a79d4572 +capability-seams.zh.md: 48e880236298fc0b475fca9fb6d23aec549c059e diff --git a/docs/capability-seams.md b/docs/capability-seams.md index dddbb3822a..134f95f0ef 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -321,7 +321,6 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b - svc_fileReferences --> pkg_apiproxy svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -434,7 +433,7 @@ flowchart LR | `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.workspaceRegistry` | `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.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | `apiproxy` | - | The interface returns path-only completion candidates within the addressed Agent cwd; providers own namespace access and ranking without reading file contents. | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents. | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 893d034b61..48e8802362 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -323,7 +323,6 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b - svc_fileReferences --> pkg_apiproxy svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -436,7 +435,7 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 | | `ctx.workspaceRegistry` | `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.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | `apiproxy` | - | 该接口返回指定 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问和排序,但不会读取文件内容。 | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | 该接口通过其一元 Remote 契约返回指定 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问和排序,但不会读取文件内容。 | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index f53cf3ea2f..4a61e2d00c 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: e6a99e2378634e04b3f649268c10bbff53967754 -event-producer-consumer.zh.md: 026fa2d9503e157500035149e7fd7c90e2c77355 +event-producer-consumer.md: f088251b46c66588bd932141df448b7312ac5c52 +event-producer-consumer.zh.md: ef2b5a45465f1a6ed985cc04496573b300fec606 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e6a99e2378..f088251b46 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,7 +10,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 026fa2d950..ef2b5a4546 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -12,7 +12,7 @@ | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index eadc4bdfef..535685927d 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.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-reference.md -session-reference.md: b21fb854c42f3199000617be0d6b2fb18f200b3e -session-reference.zh.md: 4bec1e1f5a7324d6687c77ca8cf03fe448650669 +session-reference.md: 2380584e8bdc70ac1fee28849c8d09f69d55fd9d +session-reference.zh.md: 974723e95d6f7f68d45ddb8efd65db3fa9470fc4 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index b21fb854c4..2380584e8b 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -4,7 +4,7 @@ English | [中文](session-reference.zh.md) Host-backed file discovery plus structured cross-session reference requests and prepared message contexts. The [file-reference contract](../../packages/context/file-reference) owns path-only completion records and grammar; the [session-reference contract](../../packages/context/session-reference) defines canonical URIs, current-surface projection, tag-safe JSON and byte retention, stable errors, and the untrusted model prompt. Host adapters use these types instead of passing their UI mention syntax into the agent core. -Sources: [`packages/context/file-reference/src/index.ts`](../../packages/context/file-reference/src/index.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) +Sources: [`packages/context/file-reference/src/types.ts`](../../packages/context/file-reference/src/types.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) ## File candidates @@ -50,6 +50,16 @@ interface SessionReferenceCandidate { } ``` +The `sessionReferenceResolver/candidates` Remote method serves the same discovery to browser consumers and attaches each candidate's canonical prompt mention. + +```ts type-equiv +/** One discovery candidate carrying its canonical prompt mention. */ +interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} +``` + ## Prepared messages Preparation preserves readable current-message content and returns at most one aggregated context. @@ -103,11 +113,21 @@ Host capability for cancellable file-reference discovery. * @returns deterministic path-only candidates. */ abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise ``` Types: [Agent](core.md) -Source: [`packages/context/file-reference/src/index.ts:32`](../../packages/context/file-reference/src/index.ts) +Source: [`packages/context/file-reference/src/index.ts:27`](../../packages/context/file-reference/src/index.ts) @@ -126,6 +146,17 @@ Exact-read consumer that prepares immutable cross-session message context. */ async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise +/** + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ +@Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise + /** * Snapshot all references before enqueue and return one aggregated durable context. * @param agent - target agent; references to it are rejected. @@ -139,5 +170,5 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) -Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts:75`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 4bec1e1f5a..974723e95d 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -4,7 +4,7 @@ 由 Host 支撑的文件发现,以及结构化的跨会话引用请求与准备后的消息上下文。[文件引用约定](../../packages/context/file-reference)负责仅含路径的补全记录与语法;[会话引用约定](../../packages/context/session-reference)定义规范 URI、当前表层投影、标签安全的 JSON 与字节保留、稳定错误和不可信的模型提示词。宿主适配器使用这些类型,而不会把各自 UI 的提及语法传入 agent(智能体)核心。 -来源:[`packages/context/file-reference/src/index.ts`](../../packages/context/file-reference/src/index.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) +来源:[`packages/context/file-reference/src/types.ts`](../../packages/context/file-reference/src/types.ts) · [`packages/context/session-reference/src/types.ts`](../../packages/context/session-reference/src/types.ts) ## 文件候选项 @@ -50,6 +50,16 @@ interface SessionReferenceCandidate { } ``` +`sessionReferenceResolver/candidates` Remote 方法向浏览器消费方提供同一发现能力,并为每个候选附上规范提示词 mention。 + +```ts type-equiv +/** One discovery candidate carrying its canonical prompt mention. */ +interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} +``` + ## 准备后的消息 准备过程保留可读的当前消息内容,并最多返回一个聚合上下文。 @@ -103,11 +113,21 @@ Host capability for cancellable file-reference discovery. * @returns deterministic path-only candidates. */ abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise + +/** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ +@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise ``` Types: [Agent](core.md) -Source: [`packages/context/file-reference/src/index.ts:32`](../../packages/context/file-reference/src/index.ts) +Source: [`packages/context/file-reference/src/index.ts:27`](../../packages/context/file-reference/src/index.ts) @@ -126,6 +146,17 @@ Exact-read consumer that prepares immutable cross-session message context. */ async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise +/** + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ +@Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise + /** * Snapshot all references before enqueue and return one aggregated durable context. * @param agent - target agent; references to it are rejected. @@ -139,5 +170,5 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) -Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts:75`](../../packages/context/session-reference/src/index.ts) diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 7e1662a731..f0f042eecf 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.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/typert.md -typert.md: 5840a6e85f716f4a9adfdfc24395ffab03f5c480 -typert.zh.md: 0c274d13185408313ead71b7a870b119eba2879b +typert.md: 863ab9821bbf3681ae43df817cc04018e275390c +typert.zh.md: 4883b4be51a09d8e63001284e42bd7e60497bf1d diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 5840a6e85f..863ab9821b 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -248,7 +248,7 @@ Root interface of the unified API. New client-request domain = one new file pair respond(message: ClientResponse): Promise ``` -Source: [`packages/host/apiproxy/src/api/index.ts:23`](../../packages/host/apiproxy/src/api/index.ts) +Source: [`packages/host/apiproxy/src/api/index.ts:22`](../../packages/host/apiproxy/src/api/index.ts) diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 0c274d1318..4883b4be51 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -248,7 +248,7 @@ Root interface of the unified API. New client-request domain = one new file pair respond(message: ClientResponse): Promise ``` -Source: [`packages/host/apiproxy/src/api/index.ts:23`](../../packages/host/apiproxy/src/api/index.ts) +Source: [`packages/host/apiproxy/src/api/index.ts:22`](../../packages/host/apiproxy/src/api/index.ts) diff --git a/knip.json b/knip.json index 3017292382..977a42e3af 100644 --- a/knip.json +++ b/knip.json @@ -501,6 +501,16 @@ "tests/**/*.ts" ] }, + "packages/context/file-reference": { + "ignoreDependencies": [ + "zod" + ] + }, + "packages/context/session-reference": { + "ignoreDependencies": [ + "zod" + ] + }, "packages/session/session-checkpoint-policy": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index edc6d65704..c2059492b4 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -58,37 +58,41 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 8afe0aee68..6a5164f160 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -4,16 +4,20 @@ import type { Context } from '@deepseek-ai/cordis' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' +import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' +import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' export type {} from '@deepseek-ai/dsh-commands/remote' +export type {} from '@deepseek-ai/dsh-file-reference/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' +export type {} from '@deepseek-ai/dsh-session-reference/remote' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -39,7 +43,6 @@ export type { MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt, RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem, SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, - FileReferenceItem, SessionReferenceItem, SubagentAddress, SubagentCatalog, JobView, ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' @@ -87,6 +90,10 @@ export type { // reason: a Client contribution names what it sends without importing a Host // package, and this assembly is where both planes legitimately meet. export type { JsonValue } from '@deepseek-ai/dsh-session/types' +// Reference-discovery result vocabulary for the fileReferences and +// sessionReferenceResolver namespaces. +export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' declare module '@deepseek-ai/cordis' { interface Context { @@ -107,7 +114,8 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, + commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, + pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index a0161dd047..49c7276d42 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -24,6 +24,12 @@ "path": "../../credentials/credentials" }, + { + "path": "../../context/file-reference" + }, + { + "path": "../../context/session-reference" + }, { "path": "../../extensions/cordis-host-runner" }, diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 28cccce524..f92d47b80d 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: f22ae99113f7f313c60304ac4c5fc53c8e3172ab -README.zh.md: c4aa20c9f5f317bbe5dbb6f6731f6eb6d5d7ac7c +README.md: 8665f35590335358654e0e563bea7b57585e64ae +README.zh.md: 6222c3c2cb14c28af37daf82cdf39fd84f245f6d diff --git a/packages/client/README.md b/packages/client/README.md index f22ae99113..8665f35590 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -30,6 +30,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-commands/`](ui-commands/README.md) | Provides session-aware command discovery and dispatch. | | [`ui-input-trigger/`](ui-input-trigger/README.md) | Coordinates inline command and reference suggestions. | | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | +| [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source. | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | | [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs in the conversation header. | | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index c4aa20c9f5..6222c3c2cb 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -30,6 +30,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-commands/`](ui-commands/README.md) | 提供会话感知的命令发现与分发。 | | [`ui-input-trigger/`](ui-input-trigger/README.md) | 协调内联命令和引用建议。 | | [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 | +| [`ui-reference/`](ui-reference/README.md) | 统一的 Web `@file` / `@session` 引用 source。 | | [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent(子 agent)导航、子级 transcript(文本记录)的状态和内联引用。 | | [`ui-jobs/`](ui-jobs/README.md) | 在会话标题栏列出当前会话的后台任务。 | | [`ui-model-selection/`](ui-model-selection/README.md) | 在对话界面中提供模型选择。 | diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index b65eb61c79..1b7627b293 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -11,7 +11,6 @@ export type { DirectoryEntry, DirectoryListing, ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView, SkillsApi, SkillEntry, - ReferencesApi, FileReferenceItem, SessionReferenceItem, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, GoalsApi, GoalRef, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 67389e2745..f10329f0f2 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1824,6 +1824,51 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) /** Canonical fixture implementation of the generated Goal Remote contract. */ + /** Canonical fixture implementation of the generated reference-discovery Remote contracts. */ + const referenceRemotes = { + files(id: SessionId, query: string): RpcResult<{ path: string; kind: 'file' | 'directory' }[]> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const needle = query.toLocaleLowerCase() + const items = [ + { path: 'notes', kind: 'directory' as const }, + { path: 'README.md', kind: 'file' as const }, + { path: 'notes/demo.txt', kind: 'file' as const }, + ].filter(item => item.path.toLocaleLowerCase().includes(needle)) + return { ok: true, value: items } + }, + sessions(id: SessionId, query: string): RpcResult<{ + sessionId: SessionId + label: string + cwd?: string + createdAt: number + mention: string + }[]> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const needle = query.toLocaleLowerCase() + const value = sessions + .filter(item => item.sessionId !== id) + .filter(item => String(item.sessionId).toLocaleLowerCase().includes(needle) + || item.cwd?.toLocaleLowerCase().includes(needle) === true) + .map((item) => { + const label = item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId) + const encoded = btoa(JSON.stringify(item.sessionId)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, '') + return { + sessionId: item.sessionId, + label, + ...item.cwd === undefined ? {} : { cwd: item.cwd }, + createdAt: item.updatedAt, + mention: `@[${label}](dsh-session:${encoded})`, + } + }) + return { ok: true, value } + }, + } + const goalRemotes = { create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { const missing = requireGoalSession(id) @@ -2789,43 +2834,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) }, }, - references: { - files: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const query = request.payload.query.toLocaleLowerCase() - const items = [ - { path: 'notes', kind: 'directory' as const }, - { path: 'README.md', kind: 'file' as const }, - { path: 'notes/demo.txt', kind: 'file' as const }, - ].filter(item => item.path.toLocaleLowerCase().includes(query)) - return ok(request, { items }) - }, - sessions: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const query = request.payload.query.toLocaleLowerCase() - const items = sessions - .filter(item => item.sessionId !== request.payload.sessionId) - .filter(item => String(item.sessionId).toLocaleLowerCase().includes(query) - || item.cwd?.toLocaleLowerCase().includes(query) === true) - .map((item) => { - const label = item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId) - const encoded = btoa(JSON.stringify(item.sessionId)) - .replaceAll('+', '-') - .replaceAll('/', '_') - .replace(/=+$/u, '') - return { - sessionId: item.sessionId, - label, - ...item.cwd === undefined ? {} : { cwd: item.cwd }, - createdAt: item.updatedAt, - mention: `@[${label}](dsh-session:${encoded})`, - } - }) - return ok(request, { items }) - }, - }, goals: { // Compatibility face only: old API Proxy payloads and acknowledgements // adapt to the canonical fixture Remote implementation above. @@ -3041,6 +3049,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { args: { agentId: SessionId line?: string + query?: string ref?: { id: string; revision: number } request?: { objective?: string; maxGoalRounds?: number } } @@ -3049,6 +3058,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { switch (endpoint) { case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string)) + case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) + case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { objective: args.request?.objective as string, ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, @@ -3143,8 +3154,6 @@ export class FixtureApiClient extends AbstractApiClient { case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'skill.list': return this.api.skills.list(request) - case 'reference.files': return this.api.references.files(request, signal) - case 'reference.sessions': return this.api.references.sessions(request, signal) case 'agentPreset.list': return this.api.agentPresets.list(request) case 'agentPreset.select': return this.api.agentPresets.select(request) case 'agentPreset.read': return this.api.agentPresets.read(request) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 559c1ecfac..c4470b13aa 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -19,7 +19,6 @@ export type { DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, SkillsApi, SkillEntry, - ReferencesApi, FileReferenceItem, SessionReferenceItem, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 4bcf1d4ef4..bee4fc0ce0 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -195,11 +195,6 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } - readonly references: IApiClient['references'] = { - files: (payload: unknown) => this.record('reference.files', payload, Promise.resolve(ok({ items: [] }))), - sessions: (payload: unknown) => this.record('reference.sessions', payload, Promise.resolve(ok({ items: [] }))), - } - readonly goals: IApiClient['goals'] = { create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), diff --git a/packages/client/runtime/tests/fake-api.client.ts b/packages/client/runtime/tests/fake-api.client.ts index c91d147827..33a0efbbfd 100644 --- a/packages/client/runtime/tests/fake-api.client.ts +++ b/packages/client/runtime/tests/fake-api.client.ts @@ -248,11 +248,6 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } - readonly references: IApiClient['references'] = { - files: (payload: unknown) => this.record('reference.files', payload, Promise.resolve(ok({ items: [] }))), - sessions: (payload: unknown) => this.record('reference.sessions', payload, Promise.resolve(ok({ items: [] }))), - } - readonly goals: IApiClient['goals'] = { create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index 6df31a89a3..a1c62f669c 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-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 packages/client/ui-reference/README.md -README.md: 7a96265f5db483569e3a3e6629303cb4b67a6b19 -README.zh.md: d991760bbbaf2e48e8f65447f9146a3f393c9db1 +README.md: c9ad377d2757a9dfea97c0660ca56cfb6beb6d20 +README.zh.md: 7e027abfc6237d7260fe00ff82af9a7103281804 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 7a96265f5d..c9ad377d27 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Unified Web `@file` and `@session` source. The browser starts `reference.files` and `reference.sessions` Host RPCs together for an unquoted token, deterministically orders files before sessions with stable `Folder ·`, `File ·`, and `Session ·` labels, renders the rows under the non-selectable `文件与文件夹` and `Session 对话` headings, and degrades either failed candidate domain independently. An open `@"…` token searches files only. +Unified Web `@file` and `@session` source. The browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together for an unquoted token, deterministically orders files before sessions with locale-registered folder/file/session labels, renders the rows under non-selectable file and session section headings, and degrades either failed candidate domain independently. An open `@"…` token searches files only. File picks insert the natural text defined by the shared `@path` grammar. A file closes completion and adds a trailing space; a directory keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted. @@ -20,6 +20,6 @@ Candidate browsing has no model effect. A selected file or session changes only ## Known Limitations and Deferred Work -- **Candidate failure is intentionally quiet** — one unavailable or failed reference RPC yields no rows for that domain, while prompt submission still reports session-reference preparation failures through the ordinary send path. +- **Candidate failure is intentionally quiet** — one unavailable or failed Remote discovery call yields no rows for that domain, while prompt submission still reports session-reference preparation failures through the ordinary send path. - **No browser-side file scan** — Web completion requires a mounted Host `ctx.fileReferences` provider; the browser cannot fall back to its own filesystem. - **Session search remains metadata-only** — discovery filters session id, cwd, and the latest log-backed title through `ctx.sessionReferenceResolver`; message bodies and full transcripts are not searched. diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index d991760bbb..7e027abfc6 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `reference.files` 和 `reference.sessions` 宿主 RPC,以确定性顺序把文件排在会话之前,并使用稳定的 `Folder ·`、`File ·` 和 `Session ·` 标签;各行分别渲染在不可选择的 `文件与文件夹` 和 `Session 对话` 标题下,任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。 +统一的 Web `@file` 与 `@session` source。对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 和 `sessionReferenceResolver/candidates` Remote 调用,以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签;各行分别渲染在不可选择的文件与会话分组标题下,任一候选领域的失败都会独立降级。尚未闭合的 `@"…` token 只搜索文件。 选择文件会插入共享 `@path` 语法所定义的自然文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。 @@ -20,6 +20,6 @@ ## 已知限制与暂缓事项 -- **候选失败有意保持静默**:引用 RPC 不可用或失败时,该领域不产生候选行;提示词提交仍会通过普通发送路径报告会话引用准备失败。 +- **候选失败有意保持静默**:Remote 发现调用不可用或失败时,该领域不产生候选行;提示词提交仍会通过普通发送路径报告会话引用准备失败。 - **浏览器侧不扫描文件**:Web 补全需要挂载宿主 `ctx.fileReferences` 提供方;浏览器无法回退到自身文件系统。 - **会话搜索仍仅使用元数据**:发现流程通过 `ctx.sessionReferenceResolver` 筛选 session id、cwd 和以日志为依据的最新标题;不搜索消息主体或完整 transcript(文本记录)。 diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 09d3f8edb2..f5133b54c5 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -32,8 +32,9 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger" ], "platform": "web" @@ -46,18 +47,26 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "files": [ diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 18b6bd8607..f838083f63 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -1,47 +1,71 @@ /** * Unified Web `@` reference source. File and session discovery run through - * cancellable Host RPCs in parallel with deterministic ordering and labels. + * the cancellable generated Remote namespaces in parallel with deterministic + * ordering and labels. * * @module @deepseek-ai/dsh-client-ui-reference/client */ -import type { ConnectionHandle, FileReferenceItem, SessionReferenceItem } from '@deepseek-ai/dsh-api-remotes/client' -import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. +import type {} from '@deepseek-ai/dsh-api-remotes/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, InputTriggerServiceContract, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { en, NS, zh, type ReferenceKey } from './locales.ts' -const FILE_SECTION = '文件与文件夹' -const SESSION_SECTION = 'Session 对话' +/** + * The two Remote calls this source needs. The generated face wraps every + * business result in {@link RemoteResult}: a carrier failure arrives as the + * `ok: false` branch rather than a rejection, so discovery reads one envelope + * per domain and either domain can fail without hiding the other. + */ +interface ReferenceRemotes { + readonly fileReferences: { + list: (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> + } + readonly sessionReferenceResolver: { + candidates: (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> + } +} -/** Required services: the slash registry and Host connection. */ -export const inject = ['inputTriggers', 'connection'] +/** Required services: the trigger registry, the Remote namespaces, and the copy. */ +export const inject = [ + 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', +] /** * Register the combined `@file` / `@session` source. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const references = (ctx.get('connection') as ConnectionHandle).api.references + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') + const t = ctx.locale.bind(NS) + const remote: ReferenceRemotes = ctx.remote const source: InputTriggerSource = { trigger: '@', name: 'reference', async candidates(session: ClientSessionContext, { query, quoted, signal }) { - const files = references.files({ sessionId: session.sessionId, query }, signal).then( - response => response.result.ok ? response.result.value.items : [], + const files = remote.fileReferences.list(session.sessionId, query, signal).then( + result => result.ok ? result.value : [], () => [], ) const sessions = quoted === true - ? Promise.resolve([] as SessionReferenceItem[]) - : references.sessions({ sessionId: session.sessionId, query }, signal).then( - response => response.result.ok ? response.result.value.items : [], + ? Promise.resolve([] as SessionReferenceMentionCandidate[]) + : remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( + result => result.ok ? result.value : [], () => [], ) const [fileItems, sessionItems] = await Promise.all([files, sessions]) if (signal.aborted) return [] return [ - ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true)), - ...sessionItems.map(sessionCandidate), + ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, t)), + ...sessionItems.map(candidate => sessionCandidate(candidate, t)), ] }, onPick({ candidate }) { @@ -73,11 +97,13 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => inputTriggers.registerSource(source), 'ui-reference: @ source') } +type Translate = (key: ReferenceKey) => string + type ReferenceCandidateValue = - | { kind: 'file'; fileKind: FileReferenceItem['kind']; mention: string } + | { kind: 'file'; fileKind: FileReferenceCandidate['kind']; mention: string } | { kind: 'session'; label: string; mention: string } -function fileCandidate(candidate: FileReferenceItem, preserveQuote: boolean) { +function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean, t: Translate) { const mention = formatFileMention(candidate, preserveQuote) if (mention === undefined) return [] const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) @@ -88,15 +114,15 @@ function fileCandidate(candidate: FileReferenceItem, preserveQuote: boolean) { mention, } return [{ - name: `${directory ? 'Folder' : 'File'} · ${name}${directory ? '/' : ''}`, + name: `${t(directory ? 'candidate.folder' : 'candidate.file')} · ${name}${directory ? '/' : ''}`, description: candidate.path, - section: FILE_SECTION, + section: t('section.files'), value: JSON.stringify(value), }] } -function sessionCandidate(candidate: SessionReferenceItem) { - const location = candidate.cwd ?? '(no cwd)' +function sessionCandidate(candidate: SessionReferenceMentionCandidate, t: Translate) { + const location = candidate.cwd ?? t('candidate.noCwd') const description = `${candidate.label === candidate.sessionId ? '' : `${candidate.sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` const value: ReferenceCandidateValue = { kind: 'session', @@ -104,9 +130,9 @@ function sessionCandidate(candidate: SessionReferenceItem) { mention: candidate.mention, } return { - name: `Session · ${candidate.label}`, + name: `${t('candidate.session')} · ${candidate.label}`, description, - section: SESSION_SECTION, + section: t('section.sessions'), value: JSON.stringify(value), } } diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts new file mode 100644 index 0000000000..5ccb5574f8 --- /dev/null +++ b/packages/client/ui-reference/src/client/locales.ts @@ -0,0 +1,34 @@ +/** `reference` namespace dictionaries for the unified `@` source. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'reference' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'section.files': '文件与文件夹', + 'section.sessions': 'Session 对话', + 'candidate.file': '文件', + 'candidate.folder': '文件夹', + 'candidate.session': 'Session', + 'candidate.noCwd': '(无工作目录)', +} satisfies Record + +/** The reference namespace key union. */ +export type ReferenceKey = keyof typeof zh + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The unified `@` reference menu's copy. */ + reference: ReferenceKey + } +} + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'section.files': 'Files & folders', + 'section.sessions': 'Session conversations', + 'candidate.file': 'File', + 'candidate.folder': 'Folder', + 'candidate.session': 'Session', + 'candidate.noCwd': '(no cwd)', +} satisfies Record diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 78c7946039..11357fcc23 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -1,28 +1,31 @@ /** - * Web reference source coverage: Host-backed file/session discovery, + * Web reference source coverage: Remote-backed file/session discovery, * deterministic ordering and labels, quoted-path suppression, pick projections, codec * round-trip, and registration lifecycle. */ -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import type { FileReferenceItem, SessionReferenceItem } from '@deepseek-ai/dsh-api-remotes/client' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { CandidateRequest, ClientSessionContext, InputTriggerCandidate, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' import { apply, inject } from '../src/client/index.ts' const sid = (value: string): SessionId => value as SessionId const session: ClientSessionContext = { sessionId: sid('target') } -type ReferenceResponse = - | { result: { ok: true; value: { items: T[] } } } - | { result: { ok: false; error: { code: string; message: string } } } +type RemoteEnvelope = + | { ok: true; value: T } + | { ok: false; error: { code: string; message: string; details: object } } -type ReferenceLookup = ( - payload: unknown, +type RemoteLookup = ( + agentId: SessionId, + query: string, signal?: AbortSignal, -) => Promise> +) => Promise> function request( query: string, @@ -37,30 +40,22 @@ function request( } async function bench( - files: ReferenceLookup = vi.fn(() => Promise.resolve({ - result: { - ok: true as const, - value: { - items: [ - { path: 'src', kind: 'directory' as const }, - { path: 'docs/a b.md', kind: 'file' as const }, - ], - }, - }, + files: RemoteLookup = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [ + { path: 'src', kind: 'directory' as const }, + { path: 'docs/a b.md', kind: 'file' as const }, + ], })), - sessions: ReferenceLookup = vi.fn(() => Promise.resolve({ - result: { - ok: true as const, - value: { - items: [{ - sessionId: sid('source'), - label: 'Research', - cwd: '/project', - createdAt: 1_700_000_000_000, - mention: '@[Research](dsh-session:InNvdXJjZSI)', - }], - }, - }, + sessions: RemoteLookup = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 1_700_000_000_000, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], })), ): Promise<{ ctx: Context; fiber: ReturnType; source: InputTriggerSource }> { const ctx = new Context() @@ -71,7 +66,15 @@ async function bench( return () => { source = undefined } }, }) - ctx.provide('connection', { api: { references: { files, sessions } } } as never) + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + ctx.provide('remote.fileReferences', { list: files }) + ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) + ctx.provide('locale', new LocaleRuntime(ctx)) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() if (source === undefined) throw new Error('reference source was not registered') @@ -80,80 +83,74 @@ async function bench( describe('apply', () => { it('declares its services and releases the @ reference registration on disposal', async () => { - expect(inject).toEqual(['inputTriggers', 'connection']) - const ctx = new Context() + expect(inject).toEqual([ + 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', + ]) + const { fiber } = await bench() let registered: InputTriggerSource | undefined + const ctx = new Context() ctx.provide('inputTriggers', { registerSource(source: InputTriggerSource) { registered = source return () => { registered = undefined } }, }) - ctx.provide('connection', { - api: { - references: { - files: () => Promise.resolve({ result: { ok: true, value: { items: [] } } }), - sessions: () => Promise.resolve({ result: { ok: true, value: { items: [] } } }), - }, - }, - } as never) - const fiber = ctx.plugin({ inject: [...inject], apply }) - await fiber.await() + class RemoteService extends Service { + constructor(serviceCtx: Context) { + super(serviceCtx, 'remote') + } + } + new RemoteService(ctx) + ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) }) + ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) + ctx.provide('locale', new LocaleRuntime(ctx)) + const ownFiber = ctx.plugin({ inject: [...inject], apply }) + await ownFiber.await() expect(registered).toMatchObject({ trigger: '@', name: 'reference' }) - await fiber.dispose() + await ownFiber.dispose() expect(registered).toBeUndefined() + await fiber.dispose() }) }) describe('candidates', () => { - it('starts both Host lookups together and renders files before sessions with stable labels', async () => { + it('starts both Remote lookups together and renders files before sessions with stable labels', async () => { let releaseFiles!: () => void let releaseSessions!: () => void const files = vi.fn(() => new Promise<{ - result: { ok: true; value: { items: { path: string; kind: 'file' | 'directory' }[] } } + ok: true + value: { path: string; kind: 'file' | 'directory' }[] }>((resolve) => { releaseFiles = () => { resolve({ - result: { - ok: true, - value: { - items: [ - { path: 'src', kind: 'directory' }, - { path: 'docs/a b.md', kind: 'file' }, - ], - }, - }, + ok: true, + value: [ + { path: 'src', kind: 'directory' }, + { path: 'docs/a b.md', kind: 'file' }, + ], }) } })) const sessions = vi.fn(() => new Promise<{ - result: { - ok: true - value: { - items: { - sessionId: SessionId - label: string - cwd: string - createdAt: number - mention: string - }[] - } - } + ok: true + value: { + sessionId: SessionId + label: string + cwd: string + createdAt: number + mention: string + }[] }>((resolve) => { releaseSessions = () => { resolve({ - result: { - ok: true, - value: { - items: [{ - sessionId: sid('source'), - label: 'Research', - cwd: '/project', - createdAt: 1_700_000_000_000, - mention: '@[Research](dsh-session:InNvdXJjZSI)', - }], - }, - }, + ok: true, + value: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 1_700_000_000_000, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], }) } })) @@ -165,12 +162,12 @@ describe('candidates', () => { releaseFiles() await expect(pending).resolves.toEqual([ expect.objectContaining({ - name: 'Folder · src/', + name: '文件夹 · src/', description: 'src', section: '文件与文件夹', }), expect.objectContaining({ - name: 'File · a b.md', + name: '文件 · a b.md', description: 'docs/a b.md', section: '文件与文件夹', }), @@ -185,29 +182,23 @@ describe('candidates', () => { it('suppresses sessions for an open quoted path and degrades each failed domain independently', async () => { const files = vi.fn() .mockResolvedValueOnce({ - result: { - ok: true as const, - value: { items: [{ path: 'README.md', kind: 'file' as const }] }, - }, + ok: true as const, + value: [{ path: 'README.md', kind: 'file' as const }], }) .mockRejectedValueOnce(new Error('file scan failed')) const sessions = vi.fn(() => Promise.resolve({ - result: { - ok: true as const, - value: { - items: [{ - sessionId: sid('source'), - label: 'Research', - cwd: '/project', - createdAt: 0, - mention: '@[Research](dsh-session:InNvdXJjZSI)', - }], - }, - }, + ok: true as const, + value: [{ + sessionId: sid('source'), + label: 'Research', + cwd: '/project', + createdAt: 0, + mention: '@[Research](dsh-session:InNvdXJjZSI)', + }], })) const { source } = await bench(files, sessions) const quoted = await source.candidates(session, request('READ', { quoted: true })) - expect(quoted).toEqual([expect.objectContaining({ name: 'File · README.md' })]) + expect(quoted).toEqual([expect.objectContaining({ name: '文件 · README.md' })]) expect(source.onPick({ candidate: quoted[0]!, session, @@ -229,55 +220,43 @@ describe('candidates', () => { await expect(pending).resolves.toEqual([]) }) - it('treats Host errors as empty domains and filters paths that cannot be mentioned', async () => { + it('treats Remote failures as empty domains and filters paths that cannot be mentioned', async () => { const files = vi.fn(() => Promise.resolve({ - result: { - ok: true as const, - value: { items: [{ path: 'bad\nname', kind: 'file' as const }] }, - }, + ok: true as const, + value: [{ path: 'bad\nname', kind: 'file' as const }], })) const sessions = vi.fn() .mockRejectedValueOnce(new Error('session lookup failed')) .mockResolvedValueOnce({ - result: { - ok: false as const, - error: { code: 'reference-failed', message: 'session lookup failed' }, - }, + ok: false as const, + error: { code: 'internal', message: 'session lookup failed', details: {} }, }) const { source } = await bench(files, sessions) await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) files.mockResolvedValueOnce({ - result: { - ok: false as const, - error: { code: 'reference-failed', message: 'file lookup failed' }, - }, + ok: false as const, + error: { code: 'internal', message: 'file lookup failed', details: {} }, } as never) await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) }) it('omits redundant session ids and labels sessions without a cwd', async () => { - const files = vi.fn(() => Promise.resolve({ - result: { ok: true as const, value: { items: [] } }, - })) + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) const sessions = vi.fn(() => Promise.resolve({ - result: { - ok: true as const, - value: { - items: [{ - sessionId: sid('same'), - label: 'same', - createdAt: 0, - mention: '@[same](dsh-session:InNhbWUi)', - }], - }, - }, + ok: true as const, + value: [{ + sessionId: sid('same'), + label: 'same', + createdAt: 0, + mention: '@[same](dsh-session:InNhbWUi)', + }], })) const { source } = await bench(files, sessions) await expect(source.candidates(session, request('same'))).resolves.toEqual([ expect.objectContaining({ name: 'Session · same', - description: '(no cwd) · 1970-01-01T00:00:00.000Z', + description: '(无工作目录) · 1970-01-01T00:00:00.000Z', }), ]) }) diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index 9e7c8c27e5..4fdd19dbc2 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -8,11 +8,26 @@ "src" ], "references": [ + { + "path": "../../../vendor/cordis" + }, { "path": "../../api/remotes/tsconfig.client.json" }, { - "path": "../../../vendor/cordis" + "path": "../../context/file-reference" + }, + { + "path": "../../context/session-reference" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../typert/protocol" + }, + { + "path": "../locale" }, { "path": "../runtime" @@ -21,10 +36,7 @@ "path": "../ui-input-trigger" }, { - "path": "../../context/file-reference" - }, - { - "path": "../../runtime-diagnostics/invariants" + "path": "../ui-slots" } ] } diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index 349b7f3784..fbfd8dfa06 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/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/context/README.md -README.md: fa28751e548dc4aec8e2e2711508816729f5d407 -README.zh.md: 9d12c7a854c6cb008c3806ea246ec5b5667be941 +README.md: ac1f6c302ce557adb71974c4d80b3b61e27f049c +README.zh.md: 98cc4ab9b3a3f84e9f8dfff0a91025ee697ba240 diff --git a/packages/context/README.md b/packages/context/README.md index fa28751e54..ac1f6c302c 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -2,11 +2,13 @@ English | [中文](README.zh.md) -Product plugins that add model-visible request context without defining a tool. `agent-instructions` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context`, `tmux-context`, and `session-reference` are opt-in. +Product plugins that add model-visible request context without defining a tool. `agent-instructions` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context`, `tmux-context`, `session-reference`, `file-reference`, and `file-reference-local` are opt-in. | Package | Role | ctx key | |---|---|---| | [`session-reference/`](session-reference/README.md) | Bounded snapshots of other sessions | `ctx.sessionReferenceResolver` | +| [`file-reference/`](file-reference/README.md) | File-reference discovery seam and `@file` grammar | `ctx.fileReferences` | +| [`file-reference-local/`](file-reference-local/README.md) | Local-filesystem file-reference provider | — | | [`time-context/`](time-context/README.md) | Current-time and elapsed-time context | — | | [`tmux-context/`](tmux-context/README.md) | tmux location context | — | | [`agent-instructions/`](agent-instructions/README.md) | Workspace-instruction context | — | diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index 9d12c7a854..98cc4ab9b3 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -2,11 +2,13 @@ [English](README.md) | 中文 -在不定义工具的情况下添加模型可见的请求上下文的产品插件。`agent-instructions` 包含在默认 `dsh-agent-spine-demo` 组合包中,可通过组合包配置禁用;`time-context`、`tmux-context` 和 `session-reference` 需主动启用。 +在不定义工具的情况下添加模型可见的请求上下文的产品插件。`agent-instructions` 包含在默认 `dsh-agent-spine-demo` 组合包中,可通过组合包配置禁用;`time-context`、`tmux-context`、`session-reference`、`file-reference` 和 `file-reference-local` 需主动启用。 | 包 | 职责 | ctx key | |---|---|---| | [`session-reference/`](session-reference/README.md) | 其他会话的有界快照 | `ctx.sessionReferenceResolver` | +| [`file-reference/`](file-reference/README.md) | 文件引用发现 seam 与 `@file` 语法 | `ctx.fileReferences` | +| [`file-reference-local/`](file-reference-local/README.md) | 本地文件系统文件引用提供方 | — | | [`time-context/`](time-context/README.md) | 当前时间与耗时上下文 | — | | [`tmux-context/`](tmux-context/README.md) | tmux 位置上下文 | — | | [`agent-instructions/`](agent-instructions/README.md) | 工作区指令上下文 | — | diff --git a/packages/context/file-reference/README.i18n.yaml b/packages/context/file-reference/README.i18n.yaml index de92c41665..352a6747ad 100644 --- a/packages/context/file-reference/README.i18n.yaml +++ b/packages/context/file-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 packages/context/file-reference/README.md -README.md: c65c787c2143ba88f2ac9887065b537c23f67ca2 -README.zh.md: 4c0d955bd6804f17ee99a5a65138f39391adfc95 +README.md: ae0e1ef68f927890af78767660cac094d831380c +README.zh.md: 1645dd345cd52cf5d3e02270c1fbd4c4344ada37 diff --git a/packages/context/file-reference/README.md b/packages/context/file-reference/README.md index c65c787c21..ae0e1ef68f 100644 --- a/packages/context/file-reference/README.md +++ b/packages/context/file-reference/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -File-reference discovery seam and browser-safe `@file` grammar shared by host-backed user interfaces. `ctx.fileReferences.list(agent, query, signal)` returns path-only file or directory candidates for the addressed agent; concrete providers own namespace access, ranking, caching, and invalidation. +File-reference discovery seam and browser-safe `@file` grammar shared by host-backed user interfaces. `ctx.fileReferences.list(agent, query, signal)` returns path-only file or directory candidates for the addressed agent; concrete providers own namespace access, ranking, caching, and invalidation. The same contract is remotely callable as the unary `fileReferences/list` Remote method (`@Remote` on the Service Definition, cancelled through the reserved trailing signal), so browser consumers call `ctx.remote.fileReferences.list` without an API Proxy route. `activeAtToken()` recognizes an `@path` or open `@"path with spaces` token only at the start of input or after whitespace, so email-like text does not open completion. `formatFileMention()` emits the matching prompt spelling, appends `/` to directory candidates, preserves an explicitly opened quote, and rejects control characters or embedded quotes that the editor grammar cannot represent safely. diff --git a/packages/context/file-reference/README.zh.md b/packages/context/file-reference/README.zh.md index 4c0d955bd6..1645dd345c 100644 --- a/packages/context/file-reference/README.zh.md +++ b/packages/context/file-reference/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -文件引用发现 seam,以及供宿主驱动的用户界面共享、可在浏览器中安全使用的 `@file` 语法。`ctx.fileReferences.list(agent, query, signal)` 为指定 agent(智能体)返回仅含路径的文件或目录候选;具体提供方负责命名空间访问、排序、缓存和失效处理。 +文件引用发现 seam,以及供宿主驱动的用户界面共享、可在浏览器中安全使用的 `@file` 语法。`ctx.fileReferences.list(agent, query, signal)` 为指定 agent(智能体)返回仅含路径的文件或目录候选;具体提供方负责命名空间访问、排序、缓存和失效处理。同一契约以一元 `fileReferences/list` Remote 方法对外可调(`@Remote` 标注在 Service Definition 上,经保留的末位 signal 参数取消),浏览器消费方直接调用 `ctx.remote.fileReferences.list`,无需 API Proxy 路由。 `activeAtToken()` 只在输入开头或空白后识别 `@path` 或尚未闭合的 `@"path with spaces` token,因此类似电子邮件的文本不会打开补全。`formatFileMention()` 会生成与提示词匹配的写法,为目录候选追加 `/`,保留显式打开的引号,并拒绝编辑器语法无法安全表示的控制字符或内嵌引号。 diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 0624bf331f..50ecf97ba1 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -26,6 +26,18 @@ "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" }, @@ -33,17 +45,26 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "zod": "^4.4.3" } } diff --git a/packages/context/file-reference/src/grammar.ts b/packages/context/file-reference/src/grammar.ts index 3e74248f32..86bb51a843 100644 --- a/packages/context/file-reference/src/grammar.ts +++ b/packages/context/file-reference/src/grammar.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-file-reference/grammar */ -import type { FileReferenceCandidate } from './index.ts' +import type { FileReferenceCandidate } from './types.ts' /** Active `@` token ending at the editor cursor. */ export interface ActiveAtToken { diff --git a/packages/context/file-reference/src/index.ts b/packages/context/file-reference/src/index.ts index f189416854..ca8c017236 100644 --- a/packages/context/file-reference/src/index.ts +++ b/packages/context/file-reference/src/index.ts @@ -4,24 +4,19 @@ * @module @deepseek-ai/dsh-file-reference */ -import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +import type { FileReferenceCandidate } from './types.ts' export { activeAtToken, formatFileMention } from './grammar.ts' export type { ActiveAtToken } from './grammar.ts' +export type { FileReferenceCandidate } from './types.ts' /** Model guidance for path-only references selected by a user interface. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' -/** One path-only completion candidate inside the target session cwd. */ -export interface FileReferenceCandidate { - /** User-facing path accepted by normal prompts and filesystem tools. */ - path: string - /** Directories keep completion open; files finish the mention. */ - kind: 'file' | 'directory' -} - declare module '@deepseek-ai/cordis' { interface Context { fileReferences: FileReferenceService @@ -29,7 +24,7 @@ declare module '@deepseek-ai/cordis' { } /** Host capability for cancellable file-reference discovery. */ -export abstract class FileReferenceService extends Service { +export abstract class FileReferenceService extends TypertRemoteService { constructor(ctx: Context) { super(ctx, 'fileReferences') } @@ -46,6 +41,23 @@ export abstract class FileReferenceService extends Service { query: string, signal: AbortSignal, ): Promise + + /** + * Remote face of {@link list}; the decorator cannot mark the abstract + * member, so this concrete adapter carries the identical contract. + * @param agent - target agent whose session cwd bounds discovery. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates. + */ + @Remote('list') + remoteExportList( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + return this.list(agent, query, signal) + } } export default FileReferenceService diff --git a/packages/context/file-reference/src/types.ts b/packages/context/file-reference/src/types.ts new file mode 100644 index 0000000000..3637b2b2c4 --- /dev/null +++ b/packages/context/file-reference/src/types.ts @@ -0,0 +1,13 @@ +/** + * Public file-reference discovery records. This module contains types only so + * generated Remote clients can consume it without Host runtime code. + * @module @deepseek-ai/dsh-file-reference/types + */ + +/** One path-only completion candidate inside the target session cwd. */ +export interface FileReferenceCandidate { + /** User-facing path accepted by normal prompts and filesystem tools. */ + path: string + /** Directories keep completion open; files finish the mention. */ + kind: 'file' | 'directory' +} diff --git a/packages/context/file-reference/tests/service.spec.ts b/packages/context/file-reference/tests/service.spec.ts new file mode 100644 index 0000000000..1f41ad6f79 --- /dev/null +++ b/packages/context/file-reference/tests/service.spec.ts @@ -0,0 +1,21 @@ +/** The Remote face delegates to the provider's discovery contract unchanged. */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { FileReferenceService } from '../src/index.ts' +import type { FileReferenceCandidate } from '../src/types.ts' + +describe('FileReferenceService', () => { + it('serves the Remote face through the abstract discovery member', async () => { + const candidates: FileReferenceCandidate[] = [{ path: 'src', kind: 'directory' }] + const list = vi.fn((_agent: Agent, _query: string, _signal: AbortSignal) => Promise.resolve(candidates)) + class StubProvider extends FileReferenceService { + list = list + } + const provider = new StubProvider(new Context()) + const agent = { id: 'target' } as unknown as Agent + const signal = new AbortController().signal + await expect(provider.remoteExportList(agent, 'sr', signal)).resolves.toBe(candidates) + expect(list).toHaveBeenCalledWith(agent, 'sr', signal) + }) +}) diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 2cffa62cc9..5a56be0add 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-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 packages/context/session-reference/README.md -README.md: cce2e19b949cba92dd58c31c9af0110d61fc66c1 -README.zh.md: 45deab310590909b239a92a63c049a5574c8b919 +README.md: 706838bfd475c320b589e091467b2974c9cc43ab +README.zh.md: 71e5fdca0755ea481b19aec79ad1e5ec9cf067fb diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index cce2e19b94..706838bfd4 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -6,7 +6,7 @@ English | [中文](README.zh.md) ## Public API -- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched. +- `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched. The unary `sessionReferenceResolver/candidates` Remote method serves the same discovery under the configured candidate limit and attaches each candidate's canonical mention, so browser consumers call `ctx.remote.sessionReferenceResolver.candidates` without an API Proxy route. - `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated, identified `UserMessage` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index 45deab3105..71e5fdca07 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -6,7 +6,7 @@ ## 公开 API -- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或以日志为依据的最新标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。 +- `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或以日志为依据的最新标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。一元 `sessionReferenceResolver/candidates` Remote 方法在配置的候选上限内提供同一发现能力,并为每个候选附上规范 mention,浏览器消费方直接调用 `ctx.remote.sessionReferenceResolver.candidates`,无需 API Proxy 路由。 - `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合且带标识的 `UserMessage` 上下文。任何无效引用、读取失败、取消或预算失败,都会使准备操作在宿主调用 `followup()` 或 `steer()` 之前失败。 - `encodeSessionReferenceUri()` 与 `decodeSessionReferenceUri()` 实现 `dsh-session:`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)`,`parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。解析器会拒绝显式 Markdown mention 中任何格式错误的 URI;只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。 diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 8d3e0ec487..e92a0204e8 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -22,17 +22,35 @@ "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/**/*.d.ts" + "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" ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -42,6 +60,7 @@ "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { @@ -52,6 +71,7 @@ "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 43bed833ef..b77ba8ce16 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -5,9 +5,10 @@ * @module @deepseek-ai/dsh-session-reference */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -21,7 +22,11 @@ import { } from './config.ts' import { retainReferencedSession, type ReferenceRetentionStats, type ReferencedSessionData } from './projection.ts' import { stringifyTagSafeJson } from './serialization.ts' -import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceSource } from './types.ts' +import type { + PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, + SessionReferenceMentionCandidate, SessionReferenceSource, +} from './types.ts' +import { formatSessionReferenceMention } from './uri.ts' export type * from './types.ts' export type { Config, SessionReferenceErrorCode } from './config.ts' @@ -67,7 +72,7 @@ interface RenderedSource { } /** Exact-read consumer that prepares immutable cross-session message context. */ -export class SessionReferenceResolver extends Service { +export class SessionReferenceResolver extends TypertRemoteService { static inject = ['sessionQuery'] static Config: z = z.object({ maxReferences: z.number().step(1).min(1).max(MAX_REFERENCES).default(MAX_REFERENCES), @@ -158,6 +163,28 @@ export class SessionReferenceResolver extends Service { })) } + /** + * Remote face of {@link listCandidates}: the configured candidate limit + * applies, and every candidate carries the canonical mention a host inserts + * into the prompt draft. + * @param agent - target agent; self is excluded and its cwd drives ranking. + * @param query - optional case-insensitive session-id/cwd/title substring. + * @param signal - caller cancellation. + * @returns mention-carrying candidates in rank order. + */ + @Remote('candidates') + async remoteExportCandidates( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + const candidates = await this.listCandidates(agent, query, this.config.candidateLimit, signal) + return candidates.map(candidate => ({ + ...candidate, + mention: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: candidate.label }), + })) + } + /** * Snapshot all references before enqueue and return one aggregated durable context. * @param agent - target agent; references to it are rejected. diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index e1eff84244..6d908f34b9 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -1,7 +1,13 @@ -/** Public session-reference request, candidate, and preparation records. */ +/** + * Public session-reference request, candidate, and preparation records. + * Imports stay on type-only subpaths so generated Remote clients can consume + * this module without Host runtime code. + * @module @deepseek-ai/dsh-session-reference/types + */ -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { UserMessage } from '@deepseek-ai/dsh-llm/message' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Durable source session, cited event seqs, and snapshot facts for prepared cross-session context. */ export interface SessionReferenceSource { @@ -49,6 +55,12 @@ export interface SessionReferenceCandidate { createdAt: number } +/** One discovery candidate carrying its canonical prompt mention. */ +export interface SessionReferenceMentionCandidate extends SessionReferenceCandidate { + /** Canonical `@[label](dsh-session:…)` mention serialized into the prompt draft. */ + mention: string +} + /** Direct message content and optional referenced-session context. */ export interface PreparedReferencedMessage { /** Readable message content after host mention tokens are removed. */ diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index a103554f23..d08b7da413 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -283,6 +283,24 @@ describe('session reference discovery and preparation', () => { listSessions.mockRestore() }) + it('serves the Remote face with the configured limit and canonical mentions', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same', createdAt: 10 } }) + ctx.sessions.create(SessionId('source]'), { meta: { cwd: '/same', createdAt: 20 } }) + const candidates = await ctx.sessionReferenceResolver.remoteExportCandidates( + fakeAgent(target), + '', + new AbortController().signal, + ) + expect(candidates).toEqual([{ + sessionId: SessionId('source]'), + label: 'source]', + cwd: '/same', + createdAt: 20, + mention: formatSessionReferenceMention({ sessionId: SessionId('source]'), label: 'source]' }), + }]) + }) + it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target')) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 6200e431f4..3f51251b5f 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -573,6 +573,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'target agent whose session cwd bounds discovery.' }, { name: 'query', description: 'path text following `@` or `@"`.' }, { name: 'signal', description: 'caller cancellation.' }], returns: 'deterministic path-only candidates.', }, + { + signature: '@Remote(\'list\') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise', + description: 'Remote face of list; the decorator cannot mark the abstract member, so this concrete adapter carries the identical contract.', + parameters: [{ name: 'agent', description: 'target agent whose session cwd bounds discovery.' }, { name: 'query', description: 'path text following `@` or `@"`.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'deterministic path-only candidates.', + }, ], }, { @@ -1269,6 +1275,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }], returns: 'candidates labeled by latest title or, when absent, session id.', }, + { + signature: '@Remote(\'candidates\') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise', + description: 'Remote face of listCandidates: the configured candidate limit applies, and every candidate carries the canonical mention a host inserts into the prompt draft.', + parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'mention-carrying candidates in rank order.', + }, { signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise', description: 'Snapshot all references before enqueue and return one aggregated durable context.', @@ -3878,6 +3890,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceInput', declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, + { + name: 'SessionReferenceMentionCandidate', + declaration: 'export interface SessionReferenceMentionCandidate extends SessionReferenceCandidate {\n mention: string;\n}', + }, { name: 'SessionResultFilter', declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 1629b11286..22fb19c837 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: d03714140589d5a10d2fcb308f1b1ad88d29d85e -README.zh.md: c2cd11abbb22540e14e3c7aa28acc2fc91c33466 +README.md: 69f10f1ed22d95e7c4601754957218df374a66cc +README.zh.md: 40179d51f8024105beafdc7c49e6eb8e70c7a224 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d037141405..69f10f1ed2 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -38,8 +38,6 @@ Session model selection is a session-domain contract. `session.models` returns t `session.prompt` and `subagent.prompt` accept optional request-local `clientTimeZone` provenance. When present, the Host validates and canonicalizes `UTC` or an IANA Area/Location before Agent entry, rejects invalid input with `invalid-time-zone`, and records the canonical value on that exact `user-rpc` message beside its `rpcId`. The value is not Session, connection, create, resume, or fork state; non-browser callers may omit it. -The `reference.*` domain addresses an ordinary session through the same ownership-fenced Agent lookup as other session controls. `reference.files` delegates cancellable path discovery to `ctx.fileReferences`; `reference.sessions` delegates metadata-only candidate ranking and Host-side canonical mention creation to `ctx.sessionReferenceResolver`. A missing capability returns `reference-unavailable`, while cancellation and provider failures remain distinguishable instead of becoming an authoritative empty list. - `session.prompt` parses canonical session mentions from normalized text blocks and asks `ctx.sessionReferenceResolver` to prepare every snapshot before enqueue. Parsing, cancellation, validation, reads, and budget enforcement are one admission transaction: failure enqueues no message, while success registers the separately sourced context against the readable prompt's exact message id for insertion immediately before it at pre-step entry. Ordinary discard removes the registration, and moving a queued prompt to steering preserves the association. 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. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c2cd11abbb..40179d51f8 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -38,8 +38,6 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.prompt` 和 `subagent.prompt` 接受可选的请求本地 `clientTimeZone` 来源信息。若提供该值,Host 会在进入 Agent 前校验 `UTC` 或 IANA Area/Location 并将其规范化;无效输入以 `invalid-time-zone` 拒绝,规范值则与 `rpcId` 一起记录在这条确切的 `user-rpc` 消息上。该值不属于 Session、连接、create、resume 或 fork 状态;非浏览器调用方可以省略它。 -`reference.*` 领域通过与其他会话控制相同、带所有权栅栏的 Agent 查找来寻址普通会话。`reference.files` 把可取消的路径发现委托给 `ctx.fileReferences`;`reference.sessions` 把仅基于元数据的候选排序与 Host 侧规范提及标记创建委托给 `ctx.sessionReferenceResolver`。缺少能力时返回 `reference-unavailable`;取消与提供方失败保持可区分,不会被折叠成看似权威的空列表。 - `session.prompt` 从规范化文本块中解析规范会话提及标记,并要求 `ctx.sessionReferenceResolver` 在入队前准备每个快照。解析、取消、校验、读取和预算约束共同构成一个准入事务:失败时不会有消息入队;成功时,会以可读提示词的确切消息 id 注册独立来源上下文,等待 pre-step 进入时紧邻插入到它之前。普通丢弃会移除该注册,把 queued 提示词移动到 steering(中途引导)时仍会保留两者的关联。 待处理的 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()` 修改队列。认领操作的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 43fd5fae1a..e3850b596a 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -51,7 +51,6 @@ "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", @@ -90,7 +89,6 @@ "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^" } } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index a4265a9c74..037aa5d06c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -10,7 +10,6 @@ import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus, PreStepDecision } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import type {} from '@deepseek-ai/dsh-file-reference' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' @@ -20,7 +19,6 @@ import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { - formatSessionReferenceMention, parseSessionReferenceText, type SessionReferenceInput, } from '@deepseek-ai/dsh-session-reference' @@ -1121,6 +1119,7 @@ function deliverPrompt( let cleanedUp = false let detachPreStep = (): void => {} let detachDiscard = (): void => {} + let detachDisposed = (): void => {} const cleanup = (): void => { /* v8 ignore next -- all settlement paths share this idempotent release. */ if (cleanedUp) return @@ -1128,8 +1127,14 @@ function deliverPrompt( ownership.cleanups.delete(message.id) detachPreStep() detachDiscard() + detachDisposed() } ownership.cleanups.set(message.id, cleanup) + // An agent retired with the prepared prompt still pending must not leave + // these listeners on the Host root context for the process lifetime. + detachDisposed = ctx.on('agent/disposed', ({ agent: subject }) => { + if (subject === agent) cleanup() + }) detachPreStep = ctx.on('agent/pre-step', async ({ agent: subject, messages }, next): Promise => { if (subject !== agent || !messages.some(candidate => candidate.id === message.id)) return next() cleanup() @@ -3352,85 +3357,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, - references: { - async files(request, signal) { - const { sessionId, query } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const fileReferences = ctx.get('fileReferences') - if (fileReferences === undefined) { - return err(request, { - code: 'reference-unavailable', - message: 'file reference capability unavailable', - details: { kind: 'file' }, - }) - } - const effectiveSignal = signal ?? new AbortController().signal - try { - return ok(request, { - items: await fileReferences.list(found.agent, query, effectiveSignal), - }) - } catch (error: unknown) { - if (effectiveSignal.aborted) { - return err(request, { - code: 'cancelled', - message: 'file reference listing was aborted', - details: {}, - }) - } - return err(request, { - code: 'reference-failed', - message: 'file reference listing failed', - details: { reason: String(error) }, - }) - } - }, - - async sessions(request, signal) { - const { sessionId, query } = request.payload - const found = await agentFor(sessionId) - if ('error' in found) return err(request, found.error) - const sessionReferences = ctx.get('sessionReferenceResolver') - if (sessionReferences === undefined) { - return err(request, { - code: 'reference-unavailable', - message: 'session reference capability unavailable', - details: { kind: 'session' }, - }) - } - try { - const candidates = await sessionReferences.listCandidates( - found.agent, - query, - undefined, - signal, - ) - return ok(request, { - items: candidates.map(candidate => ({ - ...candidate, - mention: formatSessionReferenceMention({ - sessionId: candidate.sessionId, - label: candidate.label, - }), - })), - }) - } catch (error: unknown) { - if (signal?.aborted === true) { - return err(request, { - code: 'cancelled', - message: 'session reference listing was aborted', - details: {}, - }) - } - return err(request, { - code: 'reference-failed', - message: 'session reference listing failed', - details: { reason: String(error) }, - }) - } - }, - }, - settings: { describe(request) { const settings = ctx.get('settings') diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 875e597e7c..b5e1d1ffd9 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -9,7 +9,6 @@ import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' -import type { ReferencesApi } from './references.ts' import type { SubagentsApi } from './subagents.ts' import type { EventsApi } from './events.ts' import type { GoalsApi } from './goals.ts' @@ -26,7 +25,6 @@ export interface ApiProxy { host: HostApi workspace: WorkspaceApi skills: SkillsApi - references: ReferencesApi agentPresets: AgentPresetsApi events: EventsApi goals: GoalsApi @@ -57,7 +55,6 @@ export type { export type { JobView } from './jobs.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { SkillsApi, SkillEntry } from './skills.ts' -export type { FileReferenceItem, ReferencesApi, SessionReferenceItem } from './references.ts' export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.ts' export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { GoalsApi, GoalId, GoalRef } from './goals.ts' diff --git a/packages/host/apiproxy/src/api/references.schema.ts b/packages/host/apiproxy/src/api/references.schema.ts deleted file mode 100644 index 59bee90e09..0000000000 --- a/packages/host/apiproxy/src/api/references.schema.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Reference-domain wire schemas. - * - * @module @deepseek-ai/dsh-host-apiproxy/api/references.schema - */ - -import { z } from 'zod' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './sessions.schema.ts' - -const referenceRequestSchema = z.object({ - sessionId: sessionIdSchema, - query: z.string(), -}) - -/** reference.files request payload. */ -export const referenceFilesRequestSchema = referenceRequestSchema satisfies - z.ZodType>> - -/** reference.files response value. */ -export const referenceFilesValueSchema = z.object({ - items: z.array(z.object({ - path: z.string(), - kind: z.union([z.literal('file'), z.literal('directory')]), - })), -}) satisfies z.ZodType>> - -/** reference.sessions request payload. */ -export const referenceSessionsRequestSchema = referenceRequestSchema satisfies - z.ZodType>> - -/** reference.sessions response value. */ -export const referenceSessionsValueSchema = z.object({ - items: z.array(z.object({ - sessionId: sessionIdSchema, - label: z.string(), - cwd: z.string().optional(), - createdAt: z.number(), - mention: z.string(), - })), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/references.ts b/packages/host/apiproxy/src/api/references.ts deleted file mode 100644 index 13471f8d46..0000000000 --- a/packages/host/apiproxy/src/api/references.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Reference autocomplete domain contract. - * - * @module @deepseek-ai/dsh-host-apiproxy/api/references - */ - -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** One file or directory candidate inside the target session workspace. */ -export interface FileReferenceItem { - path: string - kind: 'file' | 'directory' -} - -/** One metadata-only cross-session reference candidate. */ -export interface SessionReferenceItem { - sessionId: SessionId - label: string - cwd?: string - createdAt: number - /** Canonical opaque mention serialized into the prompt draft. */ - mention: string -} - -/** Host-backed file and session reference discovery. */ -export interface ReferencesApi { - /** List path candidates using the target agent's workspace boundary. */ - files( - request: RpcRequest<{ sessionId: SessionId; query: string }>, - signal?: AbortSignal, - ): Promise> - - /** List cross-session candidates excluding the target session itself. */ - sessions( - request: RpcRequest<{ sessionId: SessionId; query: string }>, - signal?: AbortSignal, - ): Promise> -} diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index e34a7701df..80dede1799 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -9,7 +9,6 @@ import type { HostApi } from './host.ts' import type { WorkspaceApi } from './workspace.ts' import type { AgentPresetsApi } from './agent-presets.ts' import type { SkillsApi } from './skills.ts' -import type { ReferencesApi } from './references.ts' import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' @@ -52,8 +51,6 @@ export interface RpcMethodMap { 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'skill.list': SkillsApi['list'] - 'reference.files': ReferencesApi['files'] - 'reference.sessions': ReferencesApi['sessions'] 'agentPreset.list': AgentPresetsApi['list'] 'agentPreset.select': AgentPresetsApi['select'] 'agentPreset.read': AgentPresetsApi['read'] diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 3fd94ffd4f..70e3ece58f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -41,10 +41,6 @@ import { workspaceRenameValueSchema, } from '../api/workspace.schema.ts' import { skillListValueSchema } from '../api/skills.schema.ts' -import { - referenceFilesValueSchema, - referenceSessionsValueSchema, -} from '../api/references.schema.ts' import { agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema, agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema, @@ -128,10 +124,6 @@ export interface IApiClient { skills: { list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> } - references: { - files(payload: RequestPayload<'reference.files'>, signal?: AbortSignal): Promise>> - sessions(payload: RequestPayload<'reference.sessions'>, signal?: AbortSignal): Promise>> - } agentPresets: { list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise>> select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise>> @@ -207,8 +199,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('skill.list', payload, signal), } - readonly references: IApiClient['references'] = { - files: (payload, signal) => this.callUnary('reference.files', payload, signal), - sessions: (payload, signal) => this.callUnary('reference.sessions', payload, signal), - } - // Annotated like every sibling, and load-bearing rather than cosmetic: // inferring this member inlines `AgentPresetEntry` into the emitted // declaration by the specifier TS picks — the host `index.ts` — which drags diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 43643b2be1..d33e8dc55b 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -44,10 +44,6 @@ import { workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' import { skillListRequestSchema } from '../api/skills.schema.ts' -import { - referenceFilesRequestSchema, - referenceSessionsRequestSchema, -} from '../api/references.schema.ts' import { agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema, agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema, @@ -121,8 +117,6 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, - 'reference.files': { schema: referenceFilesRequestSchema, invoke: (api, r, signal) => api.references.files(r, signal) }, - 'reference.sessions': { schema: referenceSessionsRequestSchema, invoke: (api, r, signal) => api.references.sessions(r, signal) }, 'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) }, 'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) }, 'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) }, @@ -178,9 +172,6 @@ function fullResponse(narrow: RpcResponse): Response { * Wire<> widening back to the exact payload (undefined-valued properties and * absent ones are indistinguishable after JSON transport). */ -// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own -// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection. -// oxlint-disable-next-line typescript/no-unnecessary-type-parameters async function handleUnary( api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal, ): Promise { diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index af8e064592..ac6c770801 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -85,7 +85,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly host: ApiProxy['host'] readonly goals: ApiProxy['goals'] readonly skills: ApiProxy['skills'] - readonly references: ApiProxy['references'] readonly agentPresets: ApiProxy['agentPresets'] readonly settings: ApiProxy['settings'] readonly credentials: ApiProxy['credentials'] @@ -114,7 +113,6 @@ export class ApiProxyService extends Service implements ApiProxy { this.host = api.host this.goals = api.goals this.skills = api.skills - this.references = api.references this.agentPresets = api.agentPresets this.settings = api.settings this.credentials = api.credentials diff --git a/packages/host/apiproxy/tests/api-proxy-references.spec.ts b/packages/host/apiproxy/tests/api-proxy-references.spec.ts index 408584e26c..921440d7f2 100644 --- a/packages/host/apiproxy/tests/api-proxy-references.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-references.spec.ts @@ -1,7 +1,7 @@ /** - * Reference RPC coverage over the real ApiProxy: addressed Host discovery, - * canonical session mentions, atomic snapshot preparation before enqueue, - * and error/cancellation behavior. + * Referenced prompt coverage over the real ApiProxy: atomic snapshot + * preparation before enqueue and error/cancellation behavior. Discovery lives + * on the owning services' Remote faces, tested in their packages. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' @@ -86,67 +86,6 @@ function stubAgent(ctx: Context, status: Agent['status'] = 'idle') { return agent } -describe('reference discovery', () => { - it('addresses the target agent and returns file candidates unchanged', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const list = vi.fn(() => Promise.resolve([ - { path: 'src', kind: 'directory' as const }, - { path: 'src/index.ts', kind: 'file' as const }, - ])) - ctx.provide('fileReferences', { list } as never) - const api = createApiProxy(ctx, DEFAULTS) - const signal = new AbortController().signal - const value = expectOk(await api.references.files( - request({ sessionId: agent.id, query: 'sr' }), - signal, - )) - expect(value.items).toEqual([ - { path: 'src', kind: 'directory' }, - { path: 'src/index.ts', kind: 'file' }, - ]) - expect(list).toHaveBeenCalledWith(agent, 'sr', signal) - }) - - it('formats metadata candidates as opaque canonical mentions', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const source = 'source-session' as SessionId - const listCandidates = vi.fn(() => Promise.resolve([{ - sessionId: source, - label: 'Research]', - cwd: '/project', - createdAt: 42, - }])) - ctx.provide('sessionReferenceResolver', { listCandidates } as never) - const api = createApiProxy(ctx, DEFAULTS) - const value = expectOk(await api.references.sessions( - request({ sessionId: agent.id, query: 'res' }), - new AbortController().signal, - )) - expect(value.items).toEqual([{ - sessionId: source, - label: 'Research]', - cwd: '/project', - createdAt: 42, - mention: formatSessionReferenceMention({ sessionId: source, label: 'Research]' }), - }]) - expect(listCandidates).toHaveBeenCalledWith(agent, 'res', undefined, expect.any(AbortSignal)) - }) - - it('fails explicitly when a reference capability is not composed', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const api = createApiProxy(ctx, DEFAULTS) - expect(expectErr(await api.references.files( - request({ sessionId: agent.id, query: '' }), - )).code).toBe('reference-unavailable') - expect(expectErr(await api.references.sessions( - request({ sessionId: agent.id, query: '' }), - )).code).toBe('reference-unavailable') - }) -}) - describe('referenced prompt preparation', () => { it('normalizes the visible mention and waits for all context preparation before enqueue', async () => { const ctx = await harness() @@ -292,6 +231,44 @@ describe('referenced prompt preparation', () => { expect(agent.inject).not.toHaveBeenCalled() }) + it('releases the admission listeners when the agent is disposed with the prompt pending', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const source = 'source-session' as SessionId + const context = createUserMessage({ + source: { kind: 'plugin' as const, plugin: 'session-reference' }, + content: [{ type: 'text' as const, text: 'snapshot' }], + }) + ctx.provide('sessionReferenceResolver', { + prepare: () => Promise.resolve({ + content: [{ type: 'text' as const, text: 'continue @Research' }], + additionalContext: context, + }), + } as never) + const api = createApiProxy(ctx, DEFAULTS) + expectOk(await api.sessions.prompt(request({ + sessionId: agent.id, + content: [{ + type: 'text' as const, + text: `continue ${formatSessionReferenceMention({ sessionId: source, label: 'Research' })}`, + }], + mode: 'queue' as const, + }))) + const queued = agent.followup.mock.calls[0]?.[0] + if (queued === undefined) throw new Error('expected queued prompt') + + agentEvents(ctx, agent).emit('agent/disposed', { agent }) + + const signal = new AbortController().signal + const batch = agent.inbox.claim('next-turn', 1) + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: batch, turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: batch }), + ) + expect(decision).toEqual({ kind: 'enter', messages: [queued] }) + }) + it('keeps prepared context paired when a queued prompt moves to steering', async () => { const ctx = await harness() const agent = stubAgent(ctx, 'running') diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 7d1afc3650..4130d8f210 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -22,7 +22,6 @@ function scriptedApi(overrides: { subagents?: Partial host?: Partial skills?: Partial - references?: Partial agentPresets?: Partial events?: Partial goals?: Partial @@ -91,11 +90,6 @@ function scriptedApi(overrides: { archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, - references: { - files: r => ok(r, { items: [] }), - sessions: r => ok(r, { items: [] }), - ...overrides.references, - }, agentPresets: { list: r => ok(r, { presets: [], authorable: false, hasDocument: false }), select: r => ok(r, { agentPreset: r.payload.agentPreset }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 47d9382371..2000f708ba 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -223,14 +223,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } }, }, - references: { - async files(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } - }, - async sessions(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } - }, - }, goals: { async create(request) { return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 66ae52f783..8902715c6b 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -73,6 +73,9 @@ describe('rpcErrorSchema', () => { details: { provider: 'p', model: 'm' }, }).code).toBe('model-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') + expect(rpcErrorSchema.parse({ code: 'reference-unavailable', message: 'm', details: { kind: 'session' } }).code).toBe('reference-unavailable') + expect(rpcErrorSchema.parse({ code: 'reference-invalid', message: 'm', details: { reason: 'r' } }).code).toBe('reference-invalid') + expect(rpcErrorSchema.parse({ code: 'reference-failed', message: 'm', details: { reason: 'r' } }).code).toBe('reference-failed') expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') @@ -84,6 +87,8 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'reference-unavailable', message: 'm', details: { kind: 'disk' } })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'reference-failed', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 9dc2ac1b78..6abd799c0e 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -62,9 +62,6 @@ { "path": "../../session-query/session-query" }, - { - "path": "../../context/file-reference" - }, { "path": "../../context/session-reference" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b862c8fcf2..0ce2a4ce80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -858,6 +858,9 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal @@ -879,6 +882,9 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings @@ -2424,18 +2430,30 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots '@deepseek-ai/dsh-file-reference': specifier: workspace:^ version: link:../../context/file-reference '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../context/session-reference + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/client/ui-settings: dependencies: @@ -3385,6 +3403,10 @@ importers: version: link:../../core/tools packages/context/file-reference: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -3395,6 +3417,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/context/file-reference-local: dependencies: @@ -3426,6 +3451,9 @@ importers: '@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:^ @@ -3451,6 +3479,9 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/context/time-context: dependencies: @@ -4789,9 +4820,6 @@ importers: '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-file-reference': - specifier: workspace:^ - version: link:../../context/file-reference '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 4e2ca42b8d..0c56dd9953 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -271,6 +271,7 @@ export const LINK_MAP: Readonly> = { PreparedReferencedMessage: 'session-reference.md', FileReferenceCandidate: 'session-reference.md', SessionReferenceCandidate: 'session-reference.md', + SessionReferenceMentionCandidate: 'session-reference.md', SessionReferenceInput: 'session-reference.md', SessionEvent: 'session.md', SessionId: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 57d1f77609..e839ad0052 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -244,8 +244,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'File reference discovery', mode: 'seam', implementations: ['file-reference-local'], - consumers: ['apiproxy'], - note: 'The interface returns path-only completion candidates within the addressed Agent cwd; providers own namespace access and ranking without reading file contents.', + note: 'The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents.', }, { key: 'sessionReferenceResolver', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 423a099544..cbd01a6fc8 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -668,13 +668,18 @@ { "doc": "docs/subsystems/session-reference.md", "symbol": "FileReferenceCandidate", - "source": "packages/context/file-reference/src/index.ts" + "source": "packages/context/file-reference/src/types.ts" }, { "doc": "docs/subsystems/session-reference.md", "symbol": "SessionReferenceCandidate", "source": "packages/context/session-reference/src/types.ts" }, + { + "doc": "docs/subsystems/session-reference.md", + "symbol": "SessionReferenceMentionCandidate", + "source": "packages/context/session-reference/src/types.ts" + }, { "doc": "docs/subsystems/session-reference.md", "symbol": "PreparedReferencedMessage", diff --git a/tsconfig.base.json b/tsconfig.base.json index ba80a3286f..5eae861962 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -87,6 +87,9 @@ "@deepseek-ai/dsh-api-remotes/invariant": ["./packages/api/remotes/src/invariant.ts"], "@deepseek-ai/dsh-compaction/checkpoint": ["./packages/compaction/compaction/src/checkpoint.ts"], "@deepseek-ai/dsh-compaction/types": ["./packages/compaction/compaction/src/types.ts"], + "@deepseek-ai/dsh-file-reference/grammar": ["./packages/context/file-reference/src/grammar.ts"], + "@deepseek-ai/dsh-file-reference/types": ["./packages/context/file-reference/src/types.ts"], + "@deepseek-ai/dsh-session-reference/types": ["./packages/context/session-reference/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-tools/types": ["./packages/core/tools/src/types.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], From b19d1643f79424e4265810479c396350b81d590f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 18:35:45 +0800 Subject: [PATCH 045/137] fix(web): harden the composer submit transaction - wire the directory pick's continue flag through InsertTextRequest to the input shell, which re-tracks at the caret so descent reopens completion (the flag was produced and forwarded but never consumed) - guard the image-only send against a second Enter during the Host round-trip - keep text appended after the sent snapshot when a submit settles ok; only interleaved edits clear with the committed content - drop the dead restoreImages left from the sink rewrite - read recall labels through a shared defensive sessionRecallLabels helper instead of unchecked casts over durable log data, and take the reference summary separator from the locale dictionary - align the composer specs merged from master with the transactional submit contract (sinks resolve SubmitOutcome, settlement is awaited, call assertions carry the AbortSignal), and restore master's forms where the divergence served nothing (queue-mode sink case, single-line filter expectation, component-identity slot lookup) --- apps/web/tests/queue-actions.e2e.ts | 4 +- packages/client/runtime/src/client/index.ts | 2 +- .../src/client/sessions/context-provenance.ts | 13 ++++ .../tests/context-provenance.client.spec.ts | Bin 4540 -> 5161 bytes .../src/client/chat/ChatNodeSeat.tsx | 10 +-- .../src/client/chat/MessageItem.tsx | 2 +- .../src/client/input/facade.ts | 27 ++++---- .../ui-conversation/src/client/input/hub.ts | 2 +- .../src/client/input/machine.ts | 8 ++- .../ui-conversation/src/client/locales.ts | 2 + .../tests/apply-inject.client.spec.tsx | 21 +++--- .../tests/input-bar.client.spec.tsx | 62 ++++++++++++------ .../tests/input-machine.client.spec.ts | 20 +++++- .../tests/input-matrix.client.spec.tsx | 12 ++-- .../input-reference-submit.client.spec.ts | 41 ++++++++++++ .../tests/input-scenarios.client.spec.tsx | 8 +-- .../tests/skeleton.client.spec.tsx | 4 +- packages/client/ui-input-trigger/src/types.ts | 2 + .../tests/service.client.spec.ts | 12 ++++ .../tests/browser-plugin.client.spec.ts | 6 +- 20 files changed, 188 insertions(+), 70 deletions(-) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 9e03e1dbc2..a10ca7188e 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -146,9 +146,7 @@ describe('web e2e: queue row actions', () => { const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) - expect(sessionEvents.filter( - event => event.type === 'user/message' && event.data.source.kind === 'user', - )).toHaveLength(1) + expect(sessionEvents.filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b5433bb573..f1ab38f830 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -85,7 +85,7 @@ export { } from './sessions/conversation.ts' export { emptyAssistantBlock } from './sessions/partial.ts' export { isTokenDelta } from './sessions/assistant-timing.ts' -export { contextForm, contextProvenance } from './sessions/context-provenance.ts' +export { contextForm, contextProvenance, sessionRecallLabels } from './sessions/context-provenance.ts' export { displayFailureMessage } from './sessions/failure-display.ts' export type { ConversationContext, ConversationContextOriginKind, diff --git a/packages/client/runtime/src/client/sessions/context-provenance.ts b/packages/client/runtime/src/client/sessions/context-provenance.ts index 5912ea388a..dbd3b2dd30 100644 --- a/packages/client/runtime/src/client/sessions/context-provenance.ts +++ b/packages/client/runtime/src/client/sessions/context-provenance.ts @@ -58,6 +58,19 @@ function joined(names: string[]): string | null { return names.length > 0 ? names.join(', ') : null } +/** + * The referenced-session labels of one durable `session-reference` recall + * source, in first-seen order; empty for every other source shape, including + * a foreign or older log whose reference entries carry no readable label. + * @param source - the logged `user/message` source, exactly as recorded. + * @returns distinct non-empty reference labels. + */ +export function sessionRecallLabels(source: unknown): string[] { + const record = asRecord(source) + if (record === null || readString(record, 'kind') !== 'session-reference') return [] + return collect(record, 'references', 'label') +} + /** * Project one durable message source onto its transcript role and producer name. * diff --git a/packages/client/runtime/tests/context-provenance.client.spec.ts b/packages/client/runtime/tests/context-provenance.client.spec.ts index e243660d601d5edbb9d2838bc5eb935102966691..b64902719c604e52f96a5f81c255583dab6a6898 100644 GIT binary patch delta 476 zcmaiwze)o^5XPGbdJqsq8xaO9ZZE9X5>SXBSXkMH6q~&{U08SH{sG}Q@(RLszJaB+ zR=$kjBcyUXi3B|eEDSTV^Zn+Vx5H=lxY*_3(R*dhHAc~+vAPEC>J z9ba+|tbo%qs51Z+ST3#BD1(Pm`Jjvppj$1l6^JF|$YF&JjYRM^Its#+U~ITm<_3t2 zzq;vTqrcMuPEWl-zq0Ob2)S!_POrVeyoFI|ltaUz{()0|{wH7diP?G`LU0kGnQ-y} z&=kdSuow4HXf`G>{;e=6+8#d4$RO+3iPh).data.source as { - kind?: string - form?: string - references?: readonly { label: string }[] - } - if (source.kind !== 'session-reference' || source.form !== 'recall' || source.references === undefined) return '' - return JSON.stringify(source.references.map(reference => reference.label)) + const labels = sessionRecallLabels((previous as ChatNode<'context'>).data.source) + return labels.length === 0 ? '' : JSON.stringify(labels) }) const referenceLabels = useMemo( () => referenceLabelsJson === '' ? [] : JSON.parse(referenceLabelsJson) as string[], diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 712bf5180f..a9c7cd6d31 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -217,7 +217,7 @@ function UserStyleBubble({ } {referenceLabels.length > 0 && (
- {t('message.referenceSummary', { labels: referenceLabels.join('、') })} + {t('message.referenceSummary', { labels: referenceLabels.join(t('message.referenceSeparator')) })}
)} diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index f1b6c1b072..02389b7e31 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -91,6 +91,8 @@ export class SessionInputShell implements SessionInput { private noticeSeq = 0 private lastDraft = '' private imageIds: readonly DraftAttachmentId[] = [] + /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ + private imageSendInFlight = false private disposed = false /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ private mirrorFn: ((text: string) => void) | undefined @@ -141,16 +143,6 @@ export class SessionInputShell implements SessionInput { this.publish() } - /** - * Restore a failed attempt before any images added after its admission. - * @param ids - failed attempt image ids. - */ - restoreImages(ids: readonly DraftAttachmentId[]): void { - const current = new Set(this.imageIds) - this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds] - this.publish() - } - /** * Clear the draft as a successful-send commit: no undo unit is recorded and * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content @@ -202,13 +194,16 @@ export class SessionInputShell implements SessionInput { */ submit(mode: InputSubmitMode = 'queue'): void { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { - if (this.snapshot.phase === 'plain') { + if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) { const imageIds = [...this.imageIds] + this.imageSendInFlight = true void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => { + this.imageSendInFlight = false if (this.disposed) return if (outcome.kind === 'success') this.commitSend(imageIds) else this.notify('error', outcome.text ?? 'prompt failed') }, (error: unknown) => { + this.imageSendInFlight = false if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error)) }) } @@ -341,13 +336,21 @@ export class SessionInputShell implements SessionInput { * a scan-derived decoration, never state. * @param text - the plain reference text to splice in (e.g. `/name `). * @param span - pick-time span snapshot (draftRev CAS). + * @param keepCompleting - re-track at the caret after the splice so an open + * token (a directory pick's trailing slash) reopens the menu. * @returns whether the text was applied. */ - insertText(text: string, span: TokenSpan): boolean { + insertText(text: string, span: TokenSpan, keepCompleting = false): boolean { const snapshot = this.core.state if (span.draftRev !== snapshot.draftRev) return false const draft = snapshot.draft this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end)) + if (keepCompleting) { + // Machine-driven draft replacement never passes through onChange, so + // re-track at the caret inside the still-open token (see space()). + const next = this.snapshot + this.deps.inputTriggers?.()?.track(next.draft, span.start + text.length, { tier: guardOf(next.phase) }, next.draftRev) + } return true } diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 80c0fa9800..1fdd271503 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -91,7 +91,7 @@ export class InputHub implements SessionInputResolver { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined), actx.on('slash/input-insert-text', req => - shell.insertText(req.text, req.span) ? true : undefined), + shell.insertText(req.text, req.span, req.continue === true) ? true : undefined), ] return () => { for (const off of offs) off() diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index fd883160a1..1b8c71419b 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -523,7 +523,13 @@ export class InputMachine { this.phase = 'plain' this.claim = undefined this.occurrences = [] - this.adopt('') + // Text appended after the sent snapshot during the Host round-trip + // survives the commit; edits interleaved with committed content cannot + // be separated from it, so only a pure suffix is retained. + const snapshot = flight.attempt.draftSnapshot + this.adopt(this.draft !== snapshot && this.draft.startsWith(snapshot) + ? this.draft.slice(snapshot.length) + : '') // Committed content is gone for good: undo must not resurrect a sent draft. this.log = [] this.redoStack = [] diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 485bd67f63..976edf004c 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -95,6 +95,7 @@ export const zh = { 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', 'message.referenceSummary': '引用会话 · {labels}', + 'message.referenceSeparator': '、', 'message.context.instructions.loaded': '已载入', 'message.context.instructions.added': '已新增', 'message.context.instructions.updated': '已更新', @@ -265,6 +266,7 @@ export const en = { 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', 'message.referenceSummary': 'Referenced session · {labels}', + 'message.referenceSeparator': ', ', 'message.context.instructions.loaded': 'loaded', 'message.context.instructions.added': 'added', 'message.context.instructions.updated': 'updated', diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 9cec01789a..7b6dc4e51d 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -2,7 +2,7 @@ // apply inject factories exercised end to end against the terminal thin // API: the strict session API (views triple, draft mirror), the // provide-channel input face (machine-sink submit choreography incl. -// optimistic clear + failure restore), the resident API (selectWorkspace +// transactional clear + failure retention), the resident API (selectWorkspace // draft carrying), the composer-bar stop face, openDetails = select action + // layout orchestration, and the closeDetails details API. Complements // chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store @@ -150,7 +150,7 @@ describe('conversation slot inject API', () => { await b.runtime.dispose() }) - it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => { + it('the provide-channel input face submits through the machine sink: trim, transactional clear, failure retains the draft', async () => { const b = await bench() const { injected } = b.conversationApi(ROOT) const { state, actions } = b.inputApi(ROOT) @@ -159,20 +159,23 @@ describe('conversation slot inject API', () => { actions.submit() expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(state.getSnapshot().draft).toBe(' ') - // Success: cleared and stays cleared. + // Success: the draft clears only after the sink settles. actions.setDraft('hello') actions.submit() - expect(state.getSnapshot().draft).toBe('') - await Promise.resolve() - expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') - // Failure: restored (draft still empty when the rejection lands). + await vi.waitFor(() => { + expect(state.getSnapshot().draft).toBe('') + }) + expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal)) + // Failure: the draft is retained through the round-trip. b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.setDraft('retry me') actions.submit() await vi.waitFor(() => { - expect(state.getSnapshot().draft).toBe('retry me') + expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) }) - // Failure landing after new typing: no clobber (restore fills empty only). + await new Promise(r => setTimeout(r, 0)) + expect(state.getSnapshot().draft).toBe('retry me') + // Failure landing after new typing: no clobber (the interleaved edit wins). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } }) actions.submit() actions.setDraft('typed during flight') diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index f7d5e02a7f..c67e901532 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -13,6 +13,7 @@ import { import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { SessionInputShell } from '../src/client/input/facade.ts' import type { ComposerAttachment } from '../src/client/contract/slots.ts' import type { DraftAttachmentId } from '../src/client/input/contract.ts' @@ -101,7 +102,12 @@ function row(id: string): ConversationSnapshot['queue'][number] { /** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */ function bench(over?: BenchOptions) { - const sink = vi.fn() + const sink = vi.fn<( + text: string, + imageIds: readonly DraftAttachmentId[], + mode: 'queue' | 'steer', + signal: AbortSignal, + ) => Promise>(() => Promise.resolve({ kind: 'success' })) const lex = over?.lexicon const session = createSnapshotStore(snapshotOf({ running: over?.running ?? false, @@ -365,15 +371,28 @@ describe('image draft rail', () => { expect(view.queryByRole('status')).toBeNull() }) - it('sends an image-only draft and removes its thumbnail', () => { + it('sends an image-only draft and removes its thumbnail', async () => { const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) - const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } - const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] }) + const extra = new File([Uint8Array.of(2)], 'extra.png', { type: 'image/png' }) + const attachments = [ + { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }, + { kind: 'image' as const, id: 'draft-2' as DraftAttachmentId, file: extra, previewUrl: 'blob:draft-2' }, + ] + const { view, textarea, sink, removeImage } = bench({ attachments }) expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false) + fireEvent.click(view.getByRole('button', { name: '移除图片 extra.png' })) + expect(removeImage).toHaveBeenCalledWith('draft-2') + let settle!: (outcome: SubmitOutcome) => void + sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue') - fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) - expect(removeImage).toHaveBeenCalledWith('draft-1') + expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) + // The sent thumbnail stays on the rail through the round-trip and leaves + // only after the success settles. + expect(view.getByRole('button', { name: '移除图片 pixel.png' })).toBeTruthy() + settle({ kind: 'success' }) + await vi.waitFor(() => { + expect(view.queryByRole('button', { name: '移除图片 pixel.png' })).toBeNull() + }) }) it('opens the original image on a single click and closes it with Escape', () => { @@ -476,8 +495,11 @@ describe('Enter semantics', () => { it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('hello', [], 'queue') + expect(sink).toHaveBeenCalledWith('hello', [], 'queue', expect.any(AbortSignal)) + // The submitting-phase lock, not draft emptiness, suppresses the repeat: + // the draft is still uncleared while the sink round-trip is in flight. fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) + fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) fireEvent.keyDown(empty.textarea, { key: 'Enter' }) @@ -501,15 +523,15 @@ describe('Enter semantics', () => { it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => { const idle = bench({ draft: 'hello' }) fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) - expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue') + expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue', expect.any(AbortSignal)) const busyCtrl = bench({ running: true, draft: 'steer with ctrl' }) fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true }) - expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer') + expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer', expect.any(AbortSignal)) const busyMeta = bench({ running: true, draft: 'steer with cmd' }) fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true }) - expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer') + expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer', expect.any(AbortSignal)) }) it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => { @@ -574,7 +596,7 @@ describe('Enter semantics', () => { const steerQueue = vi.fn() const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue }) fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) - expect(sink).toHaveBeenCalledWith('插话', [], 'steer') + expect(sink).toHaveBeenCalledWith('插话', [], 'steer', expect.any(AbortSignal)) expect(steerQueue).not.toHaveBeenCalled() }) @@ -622,7 +644,7 @@ describe('running and lock semantics', () => { expect(textarea.disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue') + expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue', expect.any(AbortSignal)) expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) @@ -631,17 +653,17 @@ describe('running and lock semantics', () => { it('running plain Enter follows the busy-state Steer preference', () => { const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer') + expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer', expect.any(AbortSignal)) }) it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => { const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' }) fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) - expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue') + expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue', expect.any(AbortSignal)) const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' }) fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) - expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue') + expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue', expect.any(AbortSignal)) }) it('running continuable subagent keeps Send beside an independent Stop', () => { @@ -661,7 +683,7 @@ describe('running and lock semantics', () => { expect(interruptButton).not.toBeNull() expect(textarea.disabled).toBe(false) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue') + expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue', expect.any(AbortSignal)) fireEvent.click(interruptButton!) expect(stop).toHaveBeenCalledTimes(1) }) @@ -718,11 +740,11 @@ describe('running and lock semantics', () => { } const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent }) fireEvent.keyDown(plain.textarea, { key: 'Enter' }) - expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue') + expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue', expect.any(AbortSignal)) const accelerated = bench({ running: true, draft: 'accelerated', subagent }) fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true }) - expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue') + expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue', expect.any(AbortSignal)) }) it('disabled (session removed) locks the textarea and chrome', () => { @@ -735,7 +757,7 @@ describe('running and lock semantics', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go', [], 'queue') + expect(sink).toHaveBeenCalledWith('go', [], 'queue', expect.any(AbortSignal)) const empty = bench() expect(empty.button.disabled).toBe(true) }) diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index 4bb4a14e7e..192d4c7efa 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -72,8 +72,8 @@ describe('input-machine: plain × enter', () => { it('non-command text falls to the default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - const effect = effectAt(m.dispatch({ type: 'enter', mode: 'steer' }), 0, 'default-sink') - expect(effect).toMatchObject({ draft: 'hello world', mode: 'steer' }) + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + expect(effect).toMatchObject({ draft: 'hello world', mode: 'queue' }) expect(effect.attempt.draftSnapshot).toBe('hello world') expect(m.state.phase).toBe('submitting') }) @@ -488,6 +488,22 @@ describe('input-machine: undo / redo', () => { expect(m.dispatch({ type: 'undo' })).toEqual([]) expect(m.state.draft).toBe('') }) + + it('keeps a suffix typed during the round-trip and drops interleaved edits with the commit', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: 'hello' }) + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + m.dispatch({ type: 'draft-changed', draft: 'hello world' }) + m.dispatch({ type: 'submit-settled', attempt: effect.attempt, ok: true }) + expect(m.state.draft).toBe(' world') + + const n = new InputMachine() + n.dispatch({ type: 'draft-changed', draft: 'hello' }) + const second = effectAt(n.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + n.dispatch({ type: 'draft-changed', draft: 'hXello' }) + n.dispatch({ type: 'submit-settled', attempt: second.attempt, ok: true }) + expect(n.state.draft).toBe('') + }) }) describe('input-machine: paste plane', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 9518318557..eff49c2ca9 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -69,7 +69,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled } function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise }) { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink }) const wiring = shell const view = mountBar(shell, over) @@ -90,13 +90,15 @@ function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: s } describe('matrix row: plain', () => { - it('enter falls to the default sink; no claim on the currency; edits free', () => { + it('enter falls to the default sink; no claim on the currency; edits free', async () => { const { textarea, shell, sink } = bench() fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue') - expect(shell.snapshot.phase).toBe('plain') + expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal)) + expect(shell.snapshot.phase).toBe('submitting') + await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + expect(shell.snapshot.claim).toBeUndefined() }) }) @@ -194,7 +196,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队', [], 'queue') + expect(sink).toHaveBeenCalledWith('排队', [], 'queue', expect.any(AbortSignal)) }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index bac940eb18..d41253a299 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -120,3 +120,44 @@ describe('reference submission', () => { expect(shell.snapshot.draft).toBe('send this') }) }) + +describe('submit transaction hardening', () => { + it('sends one image-only prompt per settlement, ignoring Enter during the round-trip', async () => { + let settle!: (outcome: SubmitOutcome) => void + const sink = vi.fn(() => new Promise((resolve) => { settle = resolve })) + const shell = new SessionInputShell({ + actx: {} as ClientContext, + defaultSink: sink, + }) + expect(shell.addImages(['img-1' as DraftAttachmentId])).toBe(true) + shell.submit('queue') + shell.submit('queue') + expect(sink).toHaveBeenCalledTimes(1) + settle({ kind: 'success' }) + await vi.waitFor(() => { + expect(shell.snapshot.imageIds).toEqual([]) + }) + + expect(shell.addImages(['img-2' as DraftAttachmentId])).toBe(true) + shell.submit('queue') + expect(sink).toHaveBeenCalledTimes(2) + }) + + it('re-tracks at the caret when a continuing insert-text splice lands (directory descent)', () => { + const track = vi.fn() + const shell = new SessionInputShell({ + actx: {} as ClientContext, + inputTriggers: () => ({ track } as unknown as InputTriggerController), + defaultSink: vi.fn(), + }) + shell.setDraft('@sr') + const applied = shell.insertText('@src/', { start: 0, end: 3, draftRev: shell.snapshot.draftRev }, true) + expect(applied).toBe(true) + expect(shell.snapshot.draft).toBe('@src/') + expect(track).toHaveBeenCalledWith('@src/', 5, { tier: 'plain' }, shell.snapshot.draftRev) + + track.mockClear() + shell.insertText(' plain ', { start: 0, end: 0, draftRev: shell.snapshot.draftRev }) + expect(track).not.toHaveBeenCalled() + }) +}) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 2a1c114226..0daadabea4 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -106,7 +106,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo register?.(inputTriggers) const actx = sessions.scope(sessionId)! const controller = inputTriggers.sessionOf(actx) - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' })) const shell = new SessionInputShell({ actx, inputTriggers: () => controller, defaultSink: sink }) // The hub's listener wiring, verbatim. actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined) @@ -243,7 +243,7 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue') }) + await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue', expect.any(AbortSignal)) }) expect(b2.executed).toHaveLength(0) }) }) @@ -297,8 +297,8 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue') }) - expect(b.shell.snapshot.phase).toBe('plain') + await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue', expect.any(AbortSignal)) }) + await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') }) expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index acf618fb2b..8f4f938253 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -31,7 +31,7 @@ import type { ViewTab } from '../src/client/contract/views.ts' /** Machine-backed wiring over a sink spy. */ function fakeWiring() { - const sink = vi.fn() + const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const })) const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink }) return { wiring: shell, sink, shell } } @@ -309,7 +309,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue') + expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue', expect.any(AbortSignal)) expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) expect(b.view.queryByText('Root')).toBeNull() }) diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 1e19957191..ba1ba81548 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -224,6 +224,8 @@ export interface InsertTextRequest { /** Literal replacement for the trigger token span (e.g. `/name `). */ readonly text: string readonly span: TokenSpan + /** Keep completion open after the splice (directory descent): the input re-tracks at the caret. */ + readonly continue?: boolean } declare module '@deepseek-ai/cordis' { diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 3cbc472c4f..e24e199d72 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -498,6 +498,18 @@ describe('pick / scoped input events', () => { expect(controller.menu.getSnapshot().open).toBe(false) }) + it('forwards a continuing text outcome so a directory pick keeps completion open', async () => { + const { controller, actx } = pickBench(() => ({ text: '@src/', continue: true })) + const texts: Array<{ text: string; continue?: boolean }> = [] + actx.on('slash/input-insert-text', (req) => { + texts.push(req) + return true + }) + await tick() + controller.pick('command', 0) + expect(texts).toEqual([{ text: '@src/', continue: true, span: { start: 0, end: 2, draftRev: 3 } }]) + }) + it('a text outcome the input declines answers false on the space path', async () => { const src: InputTriggerSource = { trigger: '/', diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 0f39e47f5a..0758235aec 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -8,7 +8,9 @@ import { } from '@deepseek-ai/dsh-client-runtime/client' import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' -import type { SubagentCatalogInjected } from '../src/client/SubagentCatalogAction.tsx' +import { + SubagentCatalogAction, type SubagentCatalogInjected, +} from '../src/client/SubagentCatalogAction.tsx' import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from '../src/client/SubagentReadOnlyComposer.tsx' @@ -93,7 +95,7 @@ describe('apply', () => { it('registers catalog actions and selects read-only subagent composers from session facts', async () => { const { ctx, face } = await fullBench(FAMILY) const catalogEntry = ctx.slots.entries('conversation.session.header.actions') - .find(entry => entry.options.id === 'subagent-catalog')! + .find(entry => entry.component === SubagentCatalogAction)! const actions = (catalogEntry.inject as unknown as (id: SessionId) => SubagentCatalogInjected)(sid('parent')) const address: SubagentAddress = { parentSessionId: sid('parent'), From 862132faca0d319c126513adc4d41381fa48930c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 18:47:11 +0800 Subject: [PATCH 046/137] test(web): cover the ui-reference node half --- .../client/ui-reference/tests/browser-plugin.client.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 11357fcc23..7c18e861e6 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -13,6 +13,7 @@ import type { import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' import { apply, inject } from '../src/client/index.ts' +import { apply as nodeApply } from '../src/index.ts' const sid = (value: string): SessionId => value as SessionId const session: ClientSessionContext = { sessionId: sid('target') } @@ -111,6 +112,10 @@ describe('apply', () => { expect(registered).toBeUndefined() await fiber.dispose() }) + + it('the node half applies without host-side behavior', () => { + expect(() => { nodeApply() }).not.toThrow() + }) }) describe('candidates', () => { From 3b869b6f6d1870dcd46e1e7dd94f500a7df4369f Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 17 Aug 2026 20:42:59 +0800 Subject: [PATCH 047/137] refactor: separate persistent Bash changes from runtime packaging --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/subsystems/terminal.i18n.yaml | 4 +- docs/subsystems/terminal.md | 2 +- docs/subsystems/terminal.zh.md | 2 +- .../shell/tool-bash-persistent/src/index.ts | 3 +- .../tests/loader-composition.spec.ts | 6 +- .../tool-bash-persistent/tests/tools.spec.ts | 4 +- .../subprocess-local/src/process-inspector.ts | 80 +++---------------- .../subprocess-local/src/terminal.ts | 16 ++-- .../tests/process-inspector.spec.ts | 71 +--------------- .../terminal/terminal-bash/src/sanitize.ts | 5 +- packages/terminal/terminal/src/index.ts | 3 - 13 files changed, 34 insertions(+), 166 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e5b36bf91c..ef4931f765 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: 2e4aad7532b061e8328f25a53c2c3b0c4bb4dfa0 +config-catalog.md: 82f6d26c79d32c6952f3bc11c96fa1c2ddceecdc config-catalog.zh.md: 958d3115447db37de248bbf30b0744308ff8dbb8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2e4aad7532..82f6d26c79 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2370,7 +2370,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-bash-persistent/src/index.ts:406`](../packages/shell/tool-bash-persistent/src/index.ts) +Source: [`packages/shell/tool-bash-persistent/src/index.ts:405`](../packages/shell/tool-bash-persistent/src/index.ts) diff --git a/docs/subsystems/terminal.i18n.yaml b/docs/subsystems/terminal.i18n.yaml index 96f05cc50a..859104d415 100644 --- a/docs/subsystems/terminal.i18n.yaml +++ b/docs/subsystems/terminal.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/terminal.md -terminal.md: c7031143117a514f8579a22fed07a1461babb15e -terminal.zh.md: 7c469c028e8b3da7c9d5012f9f49c1ae65a9bd03 +terminal.md: eedf6157b256e83d3b57e07bf738429773b87574 +terminal.zh.md: 3cc2c281e8c2090bb7e92808f932be689fd0b710 diff --git a/docs/subsystems/terminal.md b/docs/subsystems/terminal.md index c703114311..eedf6157b2 100644 --- a/docs/subsystems/terminal.md +++ b/docs/subsystems/terminal.md @@ -180,5 +180,5 @@ list(owner: Agent): TerminalSessionSnapshot[] Types: [Agent](core.md) -Source: [`packages/terminal/terminal/src/index.ts:108`](../../packages/terminal/terminal/src/index.ts) +Source: [`packages/terminal/terminal/src/index.ts:105`](../../packages/terminal/terminal/src/index.ts) diff --git a/docs/subsystems/terminal.zh.md b/docs/subsystems/terminal.zh.md index 7c469c028e..3cc2c281e8 100644 --- a/docs/subsystems/terminal.zh.md +++ b/docs/subsystems/terminal.zh.md @@ -180,5 +180,5 @@ list(owner: Agent): TerminalSessionSnapshot[] Types: [Agent](core.md) -Source: [`packages/terminal/terminal/src/index.ts:108`](../../packages/terminal/terminal/src/index.ts) +Source: [`packages/terminal/terminal/src/index.ts:105`](../../packages/terminal/terminal/src/index.ts) diff --git a/packages/shell/tool-bash-persistent/src/index.ts b/packages/shell/tool-bash-persistent/src/index.ts index b13911cb25..16d127bbe2 100644 --- a/packages/shell/tool-bash-persistent/src/index.ts +++ b/packages/shell/tool-bash-persistent/src/index.ts @@ -7,7 +7,6 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -16,7 +15,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' const TRUNCATED_MESSAGE = 'To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.' const LOST_PREFIX_MESSAGE = 'The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.\n' const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.' -const SHELL_PROMPT = CONTROLLED_PROMPT +const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ ' const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT' // One page is enough to find a just-emitted completion marker; the full // scrollback is assembled only when a command settles or needs partial output. diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index f04981f2cd..55e89bc9f4 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -84,8 +84,8 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { ' config:', ' pollIntervalMs: 10', ' exactProbeAfterMs: 20', - ' idleSilenceMs: 3000', - ' handoffGraceMs: 500', + ' idleSilenceMs: 100', + ' handoffGraceMs: 100', ' scrollbackLines: 20000', ' timeoutMs: 2000', ' disposeGraceMs: 500', @@ -131,9 +131,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { }) expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash']) - const startedAt = Date.now() await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested') - expect(Date.now() - startedAt).toBeLessThan(2_000) const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"')) expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`) expect(observed).not.toContain('DSH_PERSISTENT_BASH') diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index 88a9ffa3f4..b3de46643c 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import TerminalSessionService, { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' +import TerminalSessionService from '@deepseek-ai/dsh-terminal' import type { TerminalBackend, TerminalBackendSession, @@ -100,7 +100,7 @@ type StubMode = | 'paged-scrollback' class StubPtySession implements TerminalBackendSession { - readonly motd = CONTROLLED_PROMPT + readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ ' readonly pid = 123 statusValue: TerminalSessionStatus = { kind: 'running' } scrollback = this.motd diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index 06a44665cf..f31de010de 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -13,9 +13,9 @@ export interface ProcessIdentity { /** Injectable OS process operations used by one local PTY session. */ export interface ProcessInspector { foregroundPgid(shellPid: number): number | undefined - isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean + isStdinWaiting(pgid: number): boolean /** Return the root and its current transitive descendants, children first. */ - processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[] + processTree(rootPid: number): ProcessIdentity[] /** Return current members of one POSIX process session when the platform exposes them. */ processSession(sessionId: number): ProcessIdentity[] /** Return whether the exact identity remains a non-quiescent process. */ @@ -226,26 +226,12 @@ function syscallWaitsOnStdin( return false } -function processWaitsOnStdin( - internals: ProcessInspectorInternals, - pid: number, - processGroupId: number, - table: SyscallTable, -): boolean { - if (readLinuxStat(internals, pid)?.pgrp !== processGroupId) return false - for (const tid of numericEntries(internals, `/proc/${pid}/task`)) { - const syscall = readSyscall(internals, pid, tid) - if (syscall !== undefined && syscallWaitsOnStdin(internals, pid, syscall, table)) return true - } - return false -} - abstract class PosixProcessInspector implements ProcessInspector { constructor(protected readonly internals: ProcessInspectorInternals) {} abstract foregroundPgid(shellPid: number): number | undefined - abstract isStdinWaiting(pgid: number, scanNamespace?: boolean): boolean - abstract processTree(rootPid: number, scanNamespace?: boolean): ProcessIdentity[] + abstract isStdinWaiting(pgid: number): boolean + abstract processTree(rootPid: number): ProcessIdentity[] abstract processSession(sessionId: number): ProcessIdentity[] abstract isAlive(identity: ProcessIdentity): boolean @@ -284,34 +270,6 @@ function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdent return result } -function linuxProcessTreeFromChildren( - internals: ProcessInspectorInternals, - rootPid: number, -): ProcessIdentity[] | undefined { - const root = readLinuxStat(internals, rootPid) - if (root === undefined) return [] - const visited = new Set() - const result: ProcessIdentity[] = [] - const visit = (entry: ProcStat): boolean => { - if (visited.has(entry.pid)) return true - visited.add(entry.pid) - let children: string - try { - children = internals.readFile(`/proc/${entry.pid}/task/${entry.pid}/children`) - } catch (_unreadableChildren) { - return false - } - for (const token of children.trim().split(/\s+/)) { - if (token.length === 0 || !/^\d+$/.test(token)) continue - const child = readLinuxStat(internals, Number(token)) - if (child !== undefined && !visit(child)) return false - } - result.push({ pid: entry.pid, started: entry.started }) - return true - } - return visit(root) ? result : undefined -} - class LinuxProcessInspector extends PosixProcessInspector { constructor( private readonly arch: NodeJS.Architecture, @@ -325,32 +283,20 @@ class LinuxProcessInspector extends PosixProcessInspector { return tpgid !== undefined && tpgid > 0 ? tpgid : undefined } - isStdinWaiting(pgid: number, scanNamespace = true): boolean { + isStdinWaiting(pgid: number): boolean { const table = SYSCALLS[this.arch] if (table === undefined) return false - // A POSIX process group is normally led by PID == PGID. Interactive shells - // wait on stdin in that leader, so inspect it before walking the whole PID - // namespace. Large container PID namespaces otherwise make every PTY - // readiness poll scan thousands of unrelated processes. - if (processWaitsOnStdin(this.internals, pgid, pgid, table)) return true - if (!scanNamespace) return false for (const pid of numericEntries(this.internals, '/proc')) { - if (pid === pgid) continue - if (processWaitsOnStdin(this.internals, pid, pgid, table)) return true + if (readLinuxStat(this.internals, pid)?.pgrp !== pgid) continue + for (const tid of numericEntries(this.internals, `/proc/${pid}/task`)) { + const syscall = readSyscall(this.internals, pid, tid) + if (syscall !== undefined && syscallWaitsOnStdin(this.internals, pid, syscall, table)) return true + } } return false } - processTree(rootPid: number, scanNamespace = true): ProcessIdentity[] { - // Linux exposes each process's direct children without requiring a scan of - // the container's whole PID namespace. Fall back for kernels or procfs - // mounts that do not provide the children file. - const rooted = linuxProcessTreeFromChildren(this.internals, rootPid) - if (rooted !== undefined) return rooted - if (!scanNamespace) { - const root = readLinuxStat(this.internals, rootPid) - return root === undefined ? [] : [{ pid: root.pid, started: root.started }] - } + processTree(rootPid: number): ProcessIdentity[] { const entries = numericEntries(this.internals, '/proc').flatMap((pid) => { const stat = readLinuxStat(this.internals, pid) return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }] @@ -392,11 +338,11 @@ class MacProcessInspector extends PosixProcessInspector { } } - isStdinWaiting(_pgid: number, _scanNamespace = true): boolean { + isStdinWaiting(_pgid: number): boolean { return false } - processTree(rootPid: number, _scanNamespace = true): ProcessIdentity[] { + processTree(rootPid: number): ProcessIdentity[] { return processTree(macProcessTable(this.internals), rootPid) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index bfba98dfb9..6d818c8a7f 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -57,7 +57,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private readonly graceMs: number, ) { this.pid = terminal.pid - this.rootIdentity = inspector.processTree(this.pid, false).find(member => member.pid === this.pid) + this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) this.done = this.outcome.promise this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) }) this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => { @@ -81,14 +81,12 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { // Local inspection is synchronous; the seam returns a promise for remote transports. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async inspectForeground(): Promise { - // Readiness polling may run every few milliseconds. Track the rooted tree - // here, but reserve the full process-session sweep for teardown. - this.descendants(false, false) + this.descendants() const processGroupId = this.inspector.foregroundPgid(this.pid) if (processGroupId === undefined) return undefined return { processGroupId, - inputWaiting: this.inspector.isStdinWaiting(processGroupId, false), + inputWaiting: this.inspector.isStdinWaiting(processGroupId), } } @@ -143,22 +141,20 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { return members.filter(member => this.inspector.isAlive(member)) } - private descendants(includeSession = true, scanNamespace = true): ProcessIdentity[] { + private descendants(): ProcessIdentity[] { // Adopt newly scanned members only while the numeric root pid provably // still carries the spawned shell's start identity: after the shell dies, // a recycled pid's tree and session must not donate an unrelated // process's children to this session's signalling. Already-adopted // members keep their own start identities, which every signal rechecks. - const tree = this.inspector.processTree(this.pid, scanNamespace) + const tree = this.inspector.processTree(this.pid) const root = tree.find(member => member.pid === this.pid) const rootVerified = this.rootIdentity !== undefined && root !== undefined && root.started === this.rootIdentity.started this.trackedDescendants = this.survivors(this.unionMembers( this.trackedDescendants, - ...rootVerified - ? [tree, ...includeSession ? [this.inspector.processSession(this.pid)] : []] - : [], + ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : [], ).filter(member => member.pid !== this.pid)) return this.trackedDescendants } diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index 2402681461..c90a7b3490 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -95,9 +95,6 @@ describe('Linux process inspector', () => { fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501')) fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10)) fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12)) - fake.files.set('/proc/10/task/10/children', '12') - fake.files.set('/proc/12/task/12/children', '13') - fake.files.set('/proc/13/task/13/children', '') const inspector = createProcessInspector('linux', 'x64', fake.internals) expect(inspector.foregroundPgid(10)).toBe(40) expect(inspector.foregroundPgid(11)).toBeUndefined() @@ -127,65 +124,9 @@ describe('Linux process inspector', () => { expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']]) }) - it('walks a rooted process tree without enumerating the PID namespace', () => { - const fake = fakeInternals() - fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) - fake.files.set('/proc/10/task/10/children', '11') - fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) - fake.files.set('/proc/11/task/11/children', '') - - expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ - { pid: 11, started: '501' }, - { pid: 10, started: '500' }, - ]) - }) - - it('contains cycles in the procfs children index', () => { - const fake = fakeInternals() - fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) - fake.files.set('/proc/10/task/10/children', '11') - fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) - fake.files.set('/proc/11/task/11/children', '10') - - expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ - { pid: 11, started: '501' }, - { pid: 10, started: '500' }, - ]) - }) - - it('falls back to the PID namespace when a descendant children index is unreadable', () => { - const fake = fakeInternals() - fake.dirs.set('/proc', ['10', '11', '12']) - fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) - fake.files.set('/proc/10/task/10/children', '12 invalid 11') - fake.files.set('/proc/11/stat', stat(11, 10, 10, 10, '501', 10)) - - expect(createProcessInspector('linux', 'x64', fake.internals).processTree(10)).toEqual([ - { pid: 11, started: '501' }, - { pid: 10, started: '500' }, - ]) - }) - - it('keeps readiness inspection local when procfs has no children index', () => { - const fake = fakeInternals() - fake.files.set('/proc/10/stat', stat(10, 10, 10, 10, '500')) - const inspector = createProcessInspector('linux', 'x64', fake.internals) - - expect(inspector.processTree(10, false)).toEqual([{ pid: 10, started: '500' }]) - expect(inspector.isStdinWaiting(10, false)).toBe(false) - - const readFile = fake.internals.readFile.bind(fake.internals) - let statReads = 0 - fake.internals.readFile = (path) => { - if (path === '/proc/10/stat' && statReads++ > 0) throw new Error('process exited') - return readFile(path) - } - expect(inspector.processTree(10, false)).toEqual([]) - }) - it('detects read, select, poll, and epoll waits across non-leader threads', () => { const fake = fakeInternals() - fake.dirs.set('/proc', ['77', '100', '101']) + fake.dirs.set('/proc', ['100', '101']) fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1')) fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2')) fake.dirs.set('/proc/100/task', ['100']) @@ -215,16 +156,6 @@ describe('Linux process inspector', () => { expect(inspector.isStdinWaiting(77)).toBe(true) }) - it('checks a waiting process-group leader without scanning the PID namespace', () => { - const fake = fakeInternals() - fake.files.set('/proc/77/stat', stat(77, 77, 77, 77, '1')) - fake.dirs.set('/proc/77/task', ['77']) - fake.files.set('/proc/77/task/77/syscall', syscall(0, 0)) - - const inspector = createProcessInspector('linux', 'x64', fake.internals) - expect(inspector.isStdinWaiting(77)).toBe(true) - }) - it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => { const fake = fakeInternals() fake.dirs.set('/proc', ['100']) diff --git a/packages/terminal/terminal-bash/src/sanitize.ts b/packages/terminal/terminal-bash/src/sanitize.ts index 73f9d74e7b..1f28315fb1 100644 --- a/packages/terminal/terminal-bash/src/sanitize.ts +++ b/packages/terminal/terminal-bash/src/sanitize.ts @@ -2,11 +2,12 @@ import { Buffer } from 'node:buffer' -export { CONTROLLED_PROMPT } from '@deepseek-ai/dsh-terminal' - /** OSC marker emitted by the controlled bash before each prompt. */ export const PROMPT_MARKER_PREFIX = '133;D;' +/** Exact printable prompt emitted after the private marker. */ +export const CONTROLLED_PROMPT = 'dsh> ' + /** One sanitized chunk plus whether it contained the owned prompt marker. */ export interface SanitizedChunk { text: string diff --git a/packages/terminal/terminal/src/index.ts b/packages/terminal/terminal/src/index.ts index 6f782e3bab..d9e5444465 100644 --- a/packages/terminal/terminal/src/index.ts +++ b/packages/terminal/terminal/src/index.ts @@ -42,9 +42,6 @@ export type { } from './types.ts' export { TerminalBackendCleanupError } from './types.ts' -/** Printable prompt shared by terminal backends and persistent shell consumers. */ -export const CONTROLLED_PROMPT = 'dsh> ' - /** Opaque identity minted by {@link TerminalSessionService} for one live PTY session. */ export type TerminalSessionId = TerminalSessionIdValue From 10d0895ed623a3692e5dcd29e8164aff9c45d317 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 17 Aug 2026 20:43:08 +0800 Subject: [PATCH 048/137] test(python-sdk): cover shipped preset runtime closure --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- scripts/verify-runtime-closure.spec.ts | 103 +++++++++++++++ scripts/verify-runtime-closure.ts | 121 +++++++++++------- 5 files changed, 180 insertions(+), 52 deletions(-) create mode 100644 scripts/verify-runtime-closure.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 79be5c180b..37e39c7dbf 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: fa2f86893b730aa1ba020bd568d268ec8d9d6239 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 509bec18edb9923dd4d60d4ecf30d4fbcd9cc6d5 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 3715aa5b1148eb97faa04d5ac5778cceb47b97fc +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: a9632deb847d0aa24f376edb017bd70c4ff81302 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index fa2f86893b..3715aa5b11 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,7 +36,7 @@ Config discovery has two channels and fails loudly when both are missing: the `D Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. ### Build pipeline and artifacts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 509bec18ed..a9632deb84 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,7 +36,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都显式列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 ### 构建流水线与产物 diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts new file mode 100644 index 0000000000..ccce67c498 --- /dev/null +++ b/scripts/verify-runtime-closure.spec.ts @@ -0,0 +1,103 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { verifyRuntimeClosure } from './verify-runtime-closure.ts' + +const roots: string[] = [] + +function fixture(files: Record>): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-runtime-closure-')) + roots.push(root) + for (const [relative, value] of Object.entries(files)) { + const path = join(root, relative) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, typeof value === 'string' ? value : `${JSON.stringify(value, null, 2)}\n`) + } + return root +} + +const platforms = { + 'linux-x64': { tag: 'manylinux_2_28_x86_64', executable: 'runtime-linux-x64' }, + 'linux-arm64': { tag: 'manylinux_2_28_aarch64', executable: 'runtime-linux-arm64' }, + 'macos-arm64': { tag: 'macosx_14_0_arm64', executable: 'runtime-macos-arm64' }, +} + +function workspace(root: string, name: string, manifest: Record): void { + const packageName = name.replace('@scope/', '') + const path = join(root, 'packages/core', packageName, 'package.json') + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, `${JSON.stringify({ name, ...manifest }, null, 2)}\n`) +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('verifyRuntimeClosure', () => { + it('requires only plugins active for a Linux or macOS target', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/shared': 'workspace:^' } }, + 'python/sdk-runtime/platforms.json': platforms, + 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` +- id: tools + name: cordis:group + group: true + config: + - id: shared + name: '@scope/shared' + - id: linux + name: '@scope/linux' + disabled: !!js process.platform !== 'linux' + - id: macos + name: '@scope/macos' + disabled: !!js process.platform !== 'darwin' +`, + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.presetCount).toBe(1) + expect(result.failures).toEqual([ + 'standard preset -> @scope/linux (linux-arm64, linux-x64)', + 'standard preset -> @scope/macos (macos-arm64)', + ]) + }) + + it('treats an unsupported disabled expression as active on every target', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, + 'python/sdk-runtime/platforms.json': platforms, + 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` +- id: conditional + name: '@scope/conditional' + disabled: !!js process.env.DSH_DISABLE_CONDITIONAL === '1' +`, + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.failures).toEqual([ + 'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64)', + ]) + }) + + it('retains the required workspace-peer closure check', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/root': 'workspace:^' } }, + 'python/sdk-runtime/platforms.json': platforms, + 'apps/cli/config/agent-presets/minimal/agent.cordis.yml': '[]\n', + }) + workspace(root, '@scope/root', { + peerDependencies: { '@scope/required': 'workspace:^', '@scope/optional': 'workspace:^' }, + peerDependenciesMeta: { '@scope/optional': { optional: true } }, + }) + workspace(root, '@scope/required', {}) + workspace(root, '@scope/optional', {}) + + const result = await verifyRuntimeClosure(root) + + expect(result.workspacePackageCount).toBe(1) + expect(result.failures).toEqual(['runtime -> @scope/root -> @scope/required']) + }) +}) diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index 1287542dc2..c62ef68e37 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -30,62 +30,87 @@ interface RuntimePlatform { type RuntimePlatformManifest = Record -const root = resolve(import.meta.dirname, '..') -const { values } = parseArgs({ - args: process.argv.slice(2), - options: { manifest: { type: 'string' } }, -}) -const runtimeManifestPath = resolve(root, values.manifest ?? 'python/sdk-runtime/package.json') -const runtimeManifest = await loadManifest(runtimeManifestPath) -const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime' -const workspace = await loadWorkspacePackages() -const runtimeDependencies = runtimeManifest.dependencies ?? {} -const platforms = await loadJson(resolve(root, 'python/sdk-runtime/platforms.json')) -const parents = new Map() -const queue: string[] = [] - -for (const dependency of Object.keys(runtimeDependencies).sort()) { - if (!workspace.has(dependency)) continue - parents.set(dependency, undefined) - queue.push(dependency) +export interface RuntimeClosureResult { + failures: string[] + presetCount: number + workspacePackageCount: number } -const failures = await missingPresetPlugins(runtimeDependencies, platforms) -for (let index = 0; index < queue.length; index += 1) { - const packageName = queue[index] - if (packageName === undefined) continue - const current = workspace.get(packageName) - if (current === undefined) continue - const peers = current.manifest.peerDependencies ?? {} - const peerMeta = current.manifest.peerDependenciesMeta ?? {} - for (const peer of Object.keys(peers).sort()) { - if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue - if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue - failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`) - } - const dependencies = { - ...current.manifest.dependencies, - ...current.manifest.optionalDependencies, - } - for (const dependency of Object.keys(dependencies).sort()) { - if (!workspace.has(dependency) || parents.has(dependency)) continue - parents.set(dependency, packageName) +/** + * Check that the runtime manifest contains every shipped-preset plugin and workspace peer. + * @param root repository root containing the runtime manifest and shipped presets. + * @param manifestPath runtime manifest path relative to {@link root}. + * @returns the discovered preset count, reachable workspace package count, and violations. + */ +export async function verifyRuntimeClosure( + root: string, + manifestPath = 'python/sdk-runtime/package.json', +): Promise { + const runtimeManifest = await loadManifest(resolve(root, manifestPath)) + const runtimeName = runtimeManifest.name ?? manifestPath + const workspace = await loadWorkspacePackages(root) + const runtimeDependencies = runtimeManifest.dependencies ?? {} + const platforms = await loadJson(resolve(root, 'python/sdk-runtime/platforms.json')) + const parents = new Map() + const queue: string[] = [] + + for (const dependency of Object.keys(runtimeDependencies).sort()) { + if (!workspace.has(dependency)) continue + parents.set(dependency, undefined) queue.push(dependency) } + + const failures = await missingPresetPlugins(root, runtimeDependencies, platforms) + for (let index = 0; index < queue.length; index += 1) { + const packageName = queue[index] + if (packageName === undefined) continue + const current = workspace.get(packageName) + if (current === undefined) continue + const peers = current.manifest.peerDependencies ?? {} + const peerMeta = current.manifest.peerDependenciesMeta ?? {} + for (const peer of Object.keys(peers).sort()) { + if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue + if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue + failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`) + } + const dependencies = { + ...current.manifest.dependencies, + ...current.manifest.optionalDependencies, + } + for (const dependency of Object.keys(dependencies).sort()) { + if (!workspace.has(dependency) || parents.has(dependency)) continue + parents.set(dependency, packageName) + queue.push(dependency) + } + } + + return { + failures, + presetCount: globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length, + workspacePackageCount: queue.length, + } } -if (failures.length > 0) { - console.error('verify-runtime-closure: preset plugins or required workspace peers are missing from python/sdk-runtime dependencies:') - for (const failure of failures) console.error(` ${failure}`) - process.exit(1) +if (import.meta.main) { + const root = resolve(import.meta.dirname, '..') + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { manifest: { type: 'string' } }, + }) + const result = await verifyRuntimeClosure(root, values.manifest) + if (result.failures.length > 0) { + console.error('verify-runtime-closure: preset plugins or required workspace peers are missing from python/sdk-runtime dependencies:') + for (const failure of result.failures) console.error(` ${failure}`) + process.exitCode = 1 + } else { + console.log( + `verify-runtime-closure: ${result.presetCount} agent presets and ${result.workspacePackageCount} workspace packages form a closed runtime dependency graph.`, + ) + } } -const presetCount = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length -console.log( - `verify-runtime-closure: ${presetCount} agent presets and ${queue.length} workspace packages form a closed runtime dependency graph.`, -) - async function missingPresetPlugins( + root: string, runtimeDependencies: Readonly>, platforms: RuntimePlatformManifest, ): Promise { @@ -161,7 +186,7 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -async function loadWorkspacePackages(): Promise> { +async function loadWorkspacePackages(root: string): Promise> { const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root }) .sort() .map(relative => resolve(root, relative)) From 29de639e998cbd0ebfd0738bfcf051631ef83af3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 21:13:33 +0800 Subject: [PATCH 049/137] =?UTF-8?q?fix(web):=20=E6=8C=89=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E6=84=8F=E8=A7=81=E8=B0=83=E6=95=B4=E5=BC=95=E7=94=A8=E8=81=8C?= =?UTF-8?q?=E8=B4=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-07-21-cross-session-references.i18n.yaml | 4 +- .../2026-07-21-cross-session-references.md | 16 +- .../2026-07-21-cross-session-references.zh.md | 16 +- ...-web-file-and-session-references.i18n.yaml | 4 +- ...6-07-27-web-file-and-session-references.md | 18 +- ...7-27-web-file-and-session-references.zh.md | 18 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 33 +- docs/module-graph.zh.md | 33 +- docs/subsystems/session-reference.i18n.yaml | 4 +- docs/subsystems/session-reference.md | 4 +- docs/subsystems/session-reference.zh.md | 4 +- .../client/connection/src/client/fixture.ts | 2 +- .../src/client/chat/ChatNodeSeat.tsx | 17 +- .../src/client/chat/MessageItem.tsx | 4 +- .../src/client/contract/slots.ts | 2 - .../src/client/conversation-nodes/message.ts | 25 +- .../tests/chat-branch-tails.client.spec.tsx | 8 +- ...nversation-node-definitions.client.spec.ts | 25 ++ packages/client/ui-reference/README.i18n.yaml | 4 +- packages/client/ui-reference/README.md | 4 +- packages/client/ui-reference/README.zh.md | 4 +- .../client/ui-reference/src/client/index.ts | 23 +- .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 6 +- .../context/session-reference/README.zh.md | 6 +- .../context/session-reference/src/index.ts | 50 ++- .../tests/session-reference.spec.ts | 75 +++- .../src/client/slot-catalog.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- 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 | 4 +- packages/host/apiproxy/src/api-proxy.ts | 167 +------ packages/host/apiproxy/src/api/rpc.schema.ts | 3 - packages/host/apiproxy/src/api/rpc.ts | 3 - packages/host/apiproxy/src/api/sessions.ts | 2 +- packages/host/apiproxy/src/fetch/handler.ts | 5 +- .../tests/api-proxy-references.spec.ts | 412 ------------------ .../host/apiproxy/tests/rpc-schemas.spec.ts | 5 - packages/host/apiproxy/tsconfig.json | 3 - pnpm-lock.yaml | 3 - 46 files changed, 325 insertions(+), 729 deletions(-) delete mode 100644 packages/host/apiproxy/tests/api-proxy-references.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index 0cc877effb..40f4c554ef 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.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-21-cross-session-references.md -2026-07-21-cross-session-references.md: d9aaaca442ba939b4221351e0df3601294674587 -2026-07-21-cross-session-references.zh.md: 17c3f91225f5f5ae2c2b1a639cc9d3c1794a490b +2026-07-21-cross-session-references.md: 774a948345e3d45adbba47ef6a7edd3e6f0740b2 +2026-07-21-cross-session-references.zh.md: fd864203d17954167646edf3b1a62946f8fa3f23 diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index d9aaaca442..774a948345 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -10,7 +10,7 @@ Web users need to bring relevant work from another conversation into one new mes ## Decision -`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferenceResolver`. Hosts normalize their protocol into `SessionReferenceInput[]` and call `prepare()` before delivery. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log. +`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferenceResolver`. Its outer `agent/pre-step` listener parses canonical mentions in accepted direct user messages and calls `prepare()` without adding reference behavior to a host gateway. The service returns detached readable content plus an optional identified, frozen `UserMessage` snapshot; core agent packages do not parse session URIs or read another log. `dsh-session:` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. Web receives that URI inside the Host-produced `@[label](uri)` mention and keeps it behind an atomic session chip; text-only clients may use the same inline mention. Explicit Markdown mentions reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text. @@ -18,7 +18,7 @@ The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live ## Snapshot and projection -Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `followup()` or `steer()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session. +Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, and performs all reads in parallel. It returns no partial context: any read, cancellation, validation, or budget error ends the turn before the accepted messages enter model-visible history. Cancellation races in-flight discovery and exact reads, so the listener settles promptly even when a persistence backend cannot interrupt its pending operation. A queued message captures each source when it reaches `agent/pre-step`; later source messages, compaction, deletion, or persistence replacement cannot change the context recorded in the target session. Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compaction`. That marker is part of the compaction capability contract rather than a backend package name. Reference snapshots remain separate sourced `user/message` events, so projection excludes them as injected context and never recursively propagates an earlier snapshot. Projection also excludes shadowed pre-compaction nodes, tools and results, reasoning, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery. @@ -26,15 +26,15 @@ One aggregated context is serialized as JSON beneath a fixed untrusted-backgroun ## Message ownership -Web owns the snapshot/direct-message transaction without extending the generic inbox record. Before delivery it installs a one-shot outer `agent/pre-step` listener keyed by the prepared direct message id. When a pre-step enters with a claimed batch containing that id, the listener inserts the frozen snapshot immediately before that direct message; discarding the direct message removes the listener without writing the snapshot. Moving the queued message to steering preserves its identity and listener, so both placements reach the same pre-step boundary in the same order. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this generic delivery boundary. +The service's outer `agent/pre-step` listener calls downstream listeners first and processes only an `enter` decision. It parses each accepted direct user message, preserves that message's id while replacing canonical mentions with readable labels, and inserts the frozen snapshot immediately before that message. Queue edits and queue-to-steer relocation need no reference-specific state because the final claimed messages are the input to preparation. The [separate-context decision](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md) owns this context ordering. -Reference preparation is not a new steering protocol and does not create a turn by itself. Ordinary queued delivery and queue-to-steer relocation share the message-id-scoped pre-step listener, retaining snapshot order without a second context path. +Reference preparation is not a new delivery protocol and does not create a turn by itself. A preparation failure terminates the already accepted turn through the agent loop's existing plugin-failure path. ## Host adapters The unified Web `@` source combines session candidates with Host-backed file discovery. Session candidate lookup matches case-insensitive substrings of the session id, cwd, or latest folded title, displays that title, and falls back to the session id when a title observation is absent or fails. Lookup follows the request's cancellation signal, and session id, cwd, and mention labels escape external control characters while the canonical URI retains the original id. -Web exposes discovery through the `sessionReferenceResolver/candidates` Remote method and preparation through `session.prompt`, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. The composer retains text and chips until preparation and enqueue succeed, restoring them unchanged after failure; replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON. +Web exposes file and session discovery through generated Remote methods on their owning services, as detailed in [Web file and session references](2026-07-27-web-file-and-session-references.md). Session picks are atomic chips backed by the Host-produced canonical mention. Ordinary `session.prompt` delivery carries that mention without a reference-specific API Proxy route. Replay associates the separate session-reference context with its neighboring direct message and renders a compact source summary instead of exposing the snapshot JSON. The [automation-only ACP transport](../simplification/2026-07-23-acp-automation-only-protocol.md) deliberately does not mount session-query or session-reference services. @@ -47,15 +47,15 @@ Each of at most three references is independently capped at 65,536 UTF-8 bytes b - **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only. - **Put mention syntax in agent delivery methods** — rejected because it would make the core protocol parse one host's presentation syntax and prevent typed non-text hosts from sharing the semantic layer. - **Implement references separately in each host** — rejected because projection, security warning, retention, and persistence would drift across hosts. -- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. A domain-specific admission wrapper and the existing next-step inbox preserve the required pairing without enlarging every direct prompt. +- **Attach context to `SendOptions` and the direct prompt's inbox record** — rejected because generic delivery would own a domain transaction through admission, steering, cancellation, and observation. The domain listener can prepare the final claimed message without enlarging every direct prompt. - **Bake the prefix host-side before `followup()`** — rejected because `agent/pre-step` must inspect and rewrite only the direct prompt. Keeping the snapshot as a separate sourced message preserves that boundary and lets Web hide background bytes from the direct user bubble. - **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history. - **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity. -- **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state. +- **Reread the source after the model step enters** — rejected because target replay would depend on external mutable state instead of the logged snapshot. ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, message-id-scoped pre-step insertion, discard cleanup, queue-to-steer preservation, title isolation, missing capability, Web wire preparation, and failure-preserving Web submission. A keyless Web snapshot pins the assembled reference selection path. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, id/cwd/title candidate matching and ranking, failed title-observation fallback, candidate cancellation, control-character escaping, projection exclusions, non-recursive snapshot projection, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, pre-step parsing and insertion, downstream rejection, node-owned replay association, title isolation, and the generated Remote discovery faces. A keyless Web snapshot pins the assembled reference selection path. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index 17c3f91225..fd864203d1 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -10,7 +10,7 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 决策 -`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferenceResolver` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,并在交付前调用 `prepare()`。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。 +`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferenceResolver` 上的单一上下文消费服务。它的外层 `agent/pre-step` 监听器会解析已接受直接用户消息中的规范 mention,并调用 `prepare()`,宿主网关无需添加引用行为。该服务返回分离的可读内容和一份可选的、带标识且冻结的 `UserMessage` 快照;核心 agent 包既不解析会话 URI,也不读取其他日志。 `dsh-session:` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码,因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返,不会因分隔符产生歧义。Web 接收由 Host 生成、包含该 URI 的 `@[label](uri)` 提及标记,并把它封装为原子 session chip;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。 @@ -18,7 +18,7 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 快照与投影 -准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `followup()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 +准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分上下文:任何读取、取消、校验或预算错误都会在已接受消息进入面向模型的历史之前结束该轮次。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,监听器也能及时结束等待。queued 消息到达 `agent/pre-step` 时会捕获每个源;此后源会话新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中记录的上下文。 投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compaction` 导出的规范来源标记的检查点用户消息。该标记属于压缩能力约定的一部分,而非某个后端包名称。引用快照始终是独立且带来源的 `user/message` 事件,因此投影会把它们作为注入上下文排除,绝不递归传播早先的快照。投影还会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 @@ -26,15 +26,15 @@ Web 用户需要把另一场对话中的相关工作带入一条新消息,但 ## 消息所有权 -Web 负责快照/直接消息事务,不扩展通用收件箱记录。交付前,它会安装一个以已准备直接消息 id 为键的一次性外层 `agent/pre-step` 监听器。当 pre-step 以包含该 id 的已领取批次进入时,监听器会把冻结快照紧邻插入到该直接消息之前;丢弃直接消息会移除监听器且不写入快照。把 queued 消息移到 steering 时会保留其身份和监听器,因此两种放置方式都以相同顺序抵达同一 pre-step 边界。这一通用交付边界由[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定。 +该服务的外层 `agent/pre-step` 监听器会先调用下游监听器,并且只处理 `enter` 决策。它会解析每条已接受的直接用户消息,在把规范 mention 替换为可读标签时保留消息 id,并把冻结快照插入到该消息紧前。最终领取的消息是准备过程的输入,因此队列编辑和从 queue 移动到 steer 不需要引用专用状态。[上下文分离决策](../architecture/2026-07-24-separate-context-injection-from-turn-execution.md)规定了这一上下文顺序。 -引用准备过程不是新的 steering 协议,本身也不会创建轮次。普通 queued 交付与 queue-to-steer 移动共用以消息 id 为范围的 pre-step 监听器,无需第二条上下文路径即可保持快照顺序。 +引用准备过程不是新的投递协议,本身也不会创建轮次。准备失败会通过 agent loop 的现有插件失败路径终止已经接受的轮次。 ## 宿主适配器 统一的 Web `@` source 把会话候选与 Host 支持的文件发现组合在一起。会话候选查询会对 session id、cwd 或最新折叠后的标题执行不区分大小写的子串匹配,显示该标题,并在没有标题观察结果或标题观察失败时回退到 session id。查询遵循请求的取消信号;session id、cwd 和提及标签中的外部控制字符会被转义,但规范 URI 仍保留原始 id。 -Web 通过 `sessionReferenceResolver/candidates` Remote 方法暴露发现流程、通过 `session.prompt` 暴露准备流程,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范提及标记支撑的原子 chip。composer 会保留文本和 chip,直到准备与入队均成功;失败后会原样恢复。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 +Web 通过所属服务上的生成 Remote 方法提供文件与会话发现,详见 [Web 文件与会话引用](2026-07-27-web-file-and-session-references.md)。session 选择项是由 Host 生成的规范 mention 支撑的原子 chip。普通 `session.prompt` 投递会携带该 mention,无需引用专用 API Proxy 路由。回放会把独立的 session-reference 上下文与相邻直接消息关联起来,并渲染精简来源摘要,而不暴露快照 JSON。 [仅面向自动化的 ACP(Agent Client Protocol)传输层](../simplification/2026-07-23-acp-automation-only-protocol.md)有意不挂载会话查询或会话引用服务。 @@ -47,15 +47,15 @@ Web 通过 `sessionReferenceResolver/candidates` Remote 方法暴露发现流程 - **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠,而不是内容搜索。FTS 只改进候选发现。 - **把提及标记语法放入 agent 投递方法**:不予采纳,因为这会迫使核心协议解析某个宿主的展示语法,并阻止带类型的非文本宿主复用同一语义层。 - **在每个宿主中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。 -- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域专用的准入包装层和现有 next-step inbox 可以保持所需配对,而无需扩大每条直接提示词。 +- **把上下文附加到 `SendOptions` 和直接提示词的收件箱记录**:不予采纳,因为通用投递将不得不负责贯穿准入、steering、取消和观察的领域事务。领域监听器可以准备最终领取的消息,无需扩大每条直接提示词。 - **在调用 `followup()` 前由宿主合并前缀**:不予采纳,因为 `agent/pre-step` 必须只检查和改写直接提示词。将快照保留为独立的带来源消息,可以维持该边界,并让 Web 从直接用户气泡中隐藏背景字节。 - **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。 - **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。 -- **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。 +- **模型步骤进入后重新读取源会话**:不予采纳,因为目标回放会依赖可变的外部状态,而不是已记录的快照。 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结消息所有权、按消息 id 限定的 pre-step 插入、丢弃清理、queue-to-steer 关联保留、标题隔离、能力缺失、Web 线路准备,以及失败时保留输入的 Web 提交。一个无密钥 Web 快照会固定组装后的引用选择路径。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、按 id/cwd/标题进行候选匹配与排序、标题观察失败时的回退、候选查询取消、控制字符转义、投影排除规则、快照的非递归投影、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时的取消、逐源独立字节保留、冻结消息所有权、pre-step 解析和插入、下游拒绝、节点负责的回放关联、标题隔离,以及生成的 Remote 发现接口。一个无密钥 Web 快照会固定组装后的引用选择路径。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml index fcd057ebd7..46f6c117eb 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.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-27-web-file-and-session-references.md -2026-07-27-web-file-and-session-references.md: 5032525d6362b1afa0da387c2dd3d5f2d961139f -2026-07-27-web-file-and-session-references.zh.md: fce6ec0d35b7712d2e966932144b468f88b67862 +2026-07-27-web-file-and-session-references.md: ad8e5c53832a567bd38d1d1e560122cb8b630daa +2026-07-27-web-file-and-session-references.zh.md: acb016866efc42ef3ea9f661cb10ee1459cf1a6b diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md index 5032525d63..ad8e5c5383 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.md @@ -6,7 +6,7 @@ English | [中文](2026-07-27-web-file-and-session-references.zh.md) ## Problem -The Web composer had a reusable slash/reference trigger pipeline, but its `@` source was inert subagent-label text. Web needed Host-backed workspace-path discovery and structured cross-session snapshots without scanning the Host filesystem in the browser, binding session identity to a display label, or clearing a draft before Host-side snapshot preparation succeeded. +The Web composer had a reusable slash/reference trigger pipeline, but its `@` source was inert subagent-label text. Web needed Host-backed workspace-path discovery and structured cross-session snapshots without scanning the Host filesystem in the browser or binding session identity to a display label. ## Decision @@ -14,19 +14,19 @@ Web exposes one combined `@file` and `@session` menu through `@deepseek-ai/dsh-c The file capability follows the three-package seam: `@deepseek-ai/dsh-file-reference` owns `ctx.fileReferences`, the shared `@path` token grammar, candidate shape, and stable model guidance; `@deepseek-ai/dsh-file-reference-local` owns bounded per-agent Host-filesystem indexes, invalidation, and scoped prompt installation; `dsh-client-ui-reference` consumes the generated Remote namespaces and shared grammar. A file pick remains path-only prompt text and a directory pick retriggers completion below its trailing slash. -A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. `session.prompt` parses those mentions and calls `ctx.sessionReferenceResolver.prepare()` before delivery. Delivery binds the prepared context to the exact message id through a one-shot outer `agent/pre-step` listener that inserts the frozen snapshot immediately before that message on an enter decision; ordinary discard or agent disposal releases the listener, and queue-to-steer relocation preserves the pairing. Invalid mentions, cancellation, missing capability, source-read failure, and budget failure deliver nothing. +A session pick is an atomic composer reference. Its visible label is presentation, while its hidden value and clipboard form are the canonical `@[label](dsh-session:…)` mention produced by the Host. Ordinary `session.prompt` delivery carries that mention unchanged. The session-reference service parses accepted direct user messages at `agent/pre-step`, captures every source, replaces the canonical mention with readable text while preserving the direct message id, and inserts the frozen snapshot immediately before that message. The API Proxy contains no reference-specific route, dependency, or error code. -The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or Remote failure returns the same draft to editing. On success the logged prompt envelope remains the replay authority: the browser renders adjacent metadata-confirmed session references as separate reference chips and preserves that projection when following text is adjacent without whitespace, plus a compact session-source summary instead of the snapshot JSON baked into model content. +The input machine keeps ordinary draft text and atomic references until the default sink reports Host acceptance. Serialization or prompt transport failure returns the same draft to editing. After acceptance, reference preparation belongs to the agent turn; a malformed mention, failed source read, cancellation, or budget failure terminates that turn. The logged prompt remains the replay authority. The concrete user and steering chat-node definition associates labels from an immediately preceding session-reference context, so the renderer receives the association from its own node data and shows a compact source summary instead of snapshot JSON. ## Reference transaction ```text type @ → parallel file/session Remote calls → pick path text or canonical session chip - → serialize draft → Host parses and prepares all sessions → enqueue once - ↘ any pre-enqueue failure: retain the unchanged editable draft + → serialize draft → ordinary session.prompt enqueue + → agent/pre-step parses mentions → capture sources → context + readable prompt ``` -File lookup is advisory and cancellable; selection itself performs no read. Session preparation is authoritative and all-or-nothing because the source snapshot must be fixed before the target inbox accepts the message. +File lookup is advisory and cancellable; selection itself performs no read. Session preparation is all-or-nothing for one accepted model step. A queued message captures each source when the message is claimed, so queue edits and queue-to-steer relocation use the same path without gateway coordination. ## Alternatives considered @@ -38,12 +38,12 @@ File lookup is advisory and cancellable; selection itself performs no read. Sess **Represent sessions as plain `@label` text.** Rejected because labels are neither stable nor unique and cannot identify the source snapshot. Canonical Host-produced mentions preserve opaque session identity while keeping a readable display. -**Clear the composer before the RPC settles.** Rejected because a failed preparation would lose the only editable copy of the request and visually claim acceptance that never occurred. +**Clear the composer before prompt admission settles.** Rejected because a transport or admission failure would lose the only editable copy of the request and visually claim acceptance that never occurred. ## Verification -Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, the owning services' Remote faces, all-or-nothing prompt preparation, and draft retention across serialization and Remote failures. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition. +Package tests pin shared file grammar and ranking, cache invalidation and lifecycle cleanup, parallel Web lookup, quoted paths, independent candidate failure, cancellation, grouped headings that do not alter option indexes, file/directory continuation, canonical session chips, adjacent-reference and adjacent-text reference projection, codec round-trip, generated Remote type inference, pre-step preparation, downstream rejection, and chat-node-owned label association. The keyless assembled Web snapshot renders the available reference sections, selects a file, then selects a session reference through the real client composition. ## Consequences -Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. The new file-reference seam adds two packages whose discovery methods are unary Remote contracts on the owning services, keeps browser bundles free of Node APIs, and permits another provider to align completion with a remote filesystem. Candidate lookup failures remain quiet menu degradation; submission failures remain explicit and recoverable. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. +Web now uses the shared `@file` discovery seam and structured session-reference identity, while Host services remain the authority for filesystem and session access. File and session discovery are unary Remote contracts on the owning services, so generated client types replace handwritten RPC interfaces and browser bundles remain free of Node APIs. Candidate lookup failures remain quiet menu degradation. Reference preparation failures occur after prompt acceptance and end the agent turn. File references cost only path text plus stable conditional guidance, whereas session references retain the bounded snapshot cost and trust framing owned by `dsh-session-reference`. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md index fce6ec0d35..acb016866e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-file-and-session-references.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 `@` source 只是不会产生实际作用的 subagent 标签文本。Web 需要由宿主提供工作区路径发现和结构化跨会话快照,同时避免在浏览器中扫描宿主文件系统、把会话身份绑定到显示标签,或者在宿主侧快照准备成功前清除草稿。 +Web 输入框已有可复用的斜杠命令/引用触发流水线,但它的 `@` source 只是不会产生实际作用的 subagent 标签文本。Web 需要由宿主提供工作区路径发现和结构化跨会话快照,同时避免在浏览器中扫描宿主文件系统或把会话身份绑定到显示标签。 ## 决策 @@ -14,19 +14,19 @@ Web 通过 `@deepseek-ai/dsh-client-ui-reference` 暴露一个合并的 `@file` 文件功能遵循由三个包构成的 seam:`@deepseek-ai/dsh-file-reference` 拥有 `ctx.fileReferences`、共享 `@path` token 语法、候选形状和稳定的模型指引;`@deepseek-ai/dsh-file-reference-local` 拥有每个 agent(智能体)有界的宿主文件系统索引、失效处理和作用域内的提示词安装;`dsh-client-ui-reference` 消费生成的 Remote 命名空间与共享语法。选择文件后仍只会把路径文本写入提示词,选择目录则会在其尾部斜杠后重新触发补全。 -选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` 提及标记。`session.prompt` 会解析这些提及标记,并在递送前调用 `ctx.sessionReferenceResolver.prepare()`。递送通过一次性的外层 `agent/pre-step` 监听器把准备后的上下文绑定到精确消息 id:进入决策时把冻结快照插入到该消息紧前,普通丢弃或 agent(智能体)销毁会释放监听器,队列转 steering 的迁移保持这一配对。无效提及标记、取消、功能缺失、读取源会话失败和预算失败都不会递送消息。 +选择会话会创建一个原子的输入框引用。可见标签只用于呈现,隐藏值和剪贴板形式则是宿主生成的规范 `@[label](dsh-session:…)` mention。普通 `session.prompt` 投递会原样携带该 mention。session-reference 服务会在 `agent/pre-step` 解析已接受的直接用户消息,捕获每个源,在保留直接消息 id 的同时把规范 mention 替换为可读文本,并把冻结快照插入到该消息紧前。API Proxy 不包含引用专用路由、依赖或错误码。 -输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或 Remote 调用失败后,同一草稿会回到可编辑状态。成功后,日志中的提示词封套仍是回放的权威来源:浏览器会把相邻且经元数据确认的会话引用分别渲染为独立的引用 chip,即使后续文本无空白紧邻,也会保持该投影;同时显示精简的会话来源摘要,而不会显示嵌入模型内容中的快照 JSON。 +输入状态机在默认 sink 报告宿主已接受前,会保留普通草稿文本和原子引用。序列化或提示词传输失败后,同一草稿会回到可编辑状态。接受后,引用准备属于 agent 轮次;格式错误的 mention、源读取失败、取消或预算失败会终止该轮次。已记录的提示词仍是回放权威。具体的 user 和 steering chat-node 定义会关联紧邻前一条 session-reference 上下文中的标签,因此渲染器会从自身节点数据接收关联信息,并显示精简的来源摘要,而不是快照 JSON。 ## 引用事务 ```text type @ → parallel file/session Remote calls → pick path text or canonical session chip - → serialize draft → Host parses and prepares all sessions → enqueue once - ↘ any pre-enqueue failure: retain the unchanged editable draft + → serialize draft → ordinary session.prompt enqueue + → agent/pre-step parses mentions → capture sources → context + readable prompt ``` -文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备具有权威性,并且必须全有或全无,因为目标收件箱接受消息前必须固定源快照。 +文件查询仅供参考且可取消;选择操作本身不会读取文件。会话准备针对一个已接受的模型步骤保持全有或全无。queued 消息被领取时会捕获每个源,因此队列编辑和从 queue 移动到 steer 使用同一路径,无需网关协调。 ## 备选方案 @@ -38,12 +38,12 @@ type @ → parallel file/session Remote calls → pick path text or canonical se **用普通 `@label` 文本表示会话。** 不予采纳,因为标签既不稳定也不唯一,无法标识源快照。宿主生成的规范提及标记既能保留不透明会话身份,也能保持显示内容易读。 -**RPC 完成前清空输入框。** 不予采纳,因为准备失败会丢失请求唯一可编辑的副本,并在视觉上错误表示一个从未成功的接受操作。 +**提示词准入结算前清空输入框。** 不予采纳,因为传输或准入失败会丢失请求唯一可编辑的副本,并在视觉上错误表示一个从未成功的接受操作。 ## 验证 -包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、所属服务的 Remote 调用面、全有或全无的提示词准备,以及在序列化和 Remote 调用失败时保留草稿。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 +包(package)测试固定共享文件语法和排序、缓存失效及生命周期清理、Web 并行查询、带引号的路径、候选项独立失败、取消、不改变候选项索引的分组标题、文件/目录继续补全、规范会话 chip、相邻引用及相邻文本条件下的引用投影、codec 无损往返、生成的 Remote 类型推断、pre-step 准备、下游拒绝,以及 chat node 自有的标签关联。无密钥的装配 Web 快照会渲染可用的引用分组,并通过真实客户端组合依次选择文件和会话引用。 ## 后果 -Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。新的文件引用 seam 增加了两个包,其发现方法是所属服务上的一元 Remote 契约;浏览器 bundle 中不包含 Node API,并允许其他提供方让补全与远程文件系统对齐。候选查询失败仍会让菜单静默降级;提交失败仍会显式报告且可恢复。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 +Web 现在使用共享的 `@file` 发现 seam 和结构化会话引用身份,宿主服务仍然是文件系统与会话访问的权威来源。文件和会话发现都是所属服务上的一元 Remote 契约,因此生成的客户端类型会替代手写 RPC 接口,浏览器 bundle 中也不包含 Node API。候选查询失败仍会让菜单静默降级。引用准备失败发生在提示词已接受之后,并会结束 agent 轮次。文件引用只产生路径文本和稳定的条件式指引成本,而会话引用仍保留 `dsh-session-reference` 所拥有的有界快照开销与信任限定文本。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 4a61e2d00c..e271745350 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: f088251b46c66588bd932141df448b7312ac5c52 -event-producer-consumer.zh.md: ef2b5a45465f1a6ed985cc04496573b300fec606 +event-producer-consumer.md: 34b7d8d7cbee89753516485e35a5b4611b1b9077 +event-producer-consumer.zh.md: 3216c76a7a33c473e4ed67cfbc2c4ba7fb442c9f diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index f088251b46..34b7d8d7cb 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,12 +10,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index ef2b5a4546..3216c76a7a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -12,12 +12,12 @@ | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:13`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `apiproxy` | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `apiproxy`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:205`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:231`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:244`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:260`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:217`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 826a05243c..00dcfcd1a6 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: 2eb7c748ee0bcf6eb63d200841e35f2606958a24 -module-graph.zh.md: 35a5615914711da1f52e2ecfb938c2e134f6afcb +module-graph.md: ce9cd0f0e7bdf5be6ada822cb6ba4e975c6a2f35 +module-graph.zh.md: 631c2c535e987d2699c0cbd453ae7d8626599c8a diff --git a/docs/module-graph.md b/docs/module-graph.md index 2eb7c748ee..ce9cd0f0e7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -140,6 +140,7 @@ flowchart TD pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] + pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] @@ -171,6 +172,8 @@ flowchart TD end subgraph group_context["packages/context"] pkg_agent_instructions["agent-instructions"] + pkg_file_reference["file-reference"] + pkg_file_reference_local["file-reference-local"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -513,6 +516,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants + pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -834,6 +840,11 @@ flowchart TD pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session pkg_agent_instructions --> pkg_tools + pkg_file_reference_local --> pkg_agent + pkg_file_reference_local --> pkg_file_reference + pkg_file_reference_local --> pkg_invariants + pkg_file_reference_local --> pkg_system_prompt + pkg_file_reference_local --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction pkg_session_reference --> pkg_invariants @@ -841,6 +852,7 @@ flowchart TD pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_typert_protocol pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -1004,6 +1016,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants @@ -1011,6 +1024,7 @@ flowchart TD pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_typert_registry pkg_web_app --> pkg_invariants @@ -1261,6 +1275,15 @@ flowchart TD pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_client_locale + pkg_client_ui_reference --> pkg_client_runtime + pkg_client_ui_reference --> pkg_client_ui_input_trigger + pkg_client_ui_reference --> pkg_client_ui_slots + pkg_client_ui_reference --> pkg_file_reference + pkg_client_ui_reference --> pkg_invariants + pkg_client_ui_reference --> pkg_session_reference + pkg_client_ui_reference --> pkg_typert_protocol pkg_client_ui_settings_general --> pkg_api_remotes pkg_client_ui_settings_general --> pkg_client_connection pkg_client_ui_settings_general --> pkg_client_locale @@ -1340,7 +1363,6 @@ flowchart TD pkg_client_ui_subagent --> pkg_client_locale pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_input_trigger pkg_client_ui_subagent --> pkg_client_ui_primitives pkg_client_ui_subagent --> pkg_client_ui_slots pkg_client_ui_subagent --> pkg_invariants @@ -1500,6 +1522,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1560,7 +1583,8 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1588,7 +1612,7 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1624,6 +1648,7 @@ flowchart TD | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-slots`](../packages/client/ui-slots), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1633,7 +1658,7 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `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/runtime-diagnostics/invariants) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-plan`](../packages/client/ui-plan) | `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), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-tool`](../packages/client/ui-tool) | `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), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`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/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 35a5615914..631c2c535e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -142,6 +142,7 @@ flowchart TD pkg_client_ui_permission_presets["client-ui-permission-presets"] pkg_client_ui_plan["client-ui-plan"] pkg_client_ui_primitives["client-ui-primitives"] + pkg_client_ui_reference["client-ui-reference"] pkg_client_ui_settings["client-ui-settings"] pkg_client_ui_settings_general["client-ui-settings-general"] pkg_client_ui_settings_models["client-ui-settings-models"] @@ -173,6 +174,8 @@ flowchart TD end subgraph group_context["packages/context"] pkg_agent_instructions["agent-instructions"] + pkg_file_reference["file-reference"] + pkg_file_reference_local["file-reference-local"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] pkg_tmux_context["tmux-context"] @@ -515,6 +518,9 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_file_reference --> pkg_agent + pkg_file_reference --> pkg_invariants + pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -836,6 +842,11 @@ flowchart TD pkg_agent_instructions --> pkg_llm pkg_agent_instructions --> pkg_session pkg_agent_instructions --> pkg_tools + pkg_file_reference_local --> pkg_agent + pkg_file_reference_local --> pkg_file_reference + pkg_file_reference_local --> pkg_invariants + pkg_file_reference_local --> pkg_system_prompt + pkg_file_reference_local --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compaction pkg_session_reference --> pkg_invariants @@ -843,6 +854,7 @@ flowchart TD pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_typert_protocol pkg_cordis_host_runner --> pkg_agent pkg_cordis_host_runner --> pkg_brand pkg_cordis_host_runner --> pkg_invariants @@ -1006,6 +1018,7 @@ flowchart TD pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference pkg_api_remotes --> pkg_goal pkg_api_remotes --> pkg_host_plugin_inventory pkg_api_remotes --> pkg_invariants @@ -1013,6 +1026,7 @@ flowchart TD pkg_api_remotes --> pkg_message_feedback pkg_api_remotes --> pkg_session pkg_api_remotes --> pkg_session_persistence + pkg_api_remotes --> pkg_session_reference pkg_api_remotes --> pkg_settings pkg_api_remotes --> pkg_typert_registry pkg_web_app --> pkg_invariants @@ -1263,6 +1277,15 @@ flowchart TD pkg_client_ui_layout --> pkg_client_ui_slots pkg_client_ui_layout --> pkg_client_ui_theme pkg_client_ui_layout --> pkg_invariants + pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_client_locale + pkg_client_ui_reference --> pkg_client_runtime + pkg_client_ui_reference --> pkg_client_ui_input_trigger + pkg_client_ui_reference --> pkg_client_ui_slots + pkg_client_ui_reference --> pkg_file_reference + pkg_client_ui_reference --> pkg_invariants + pkg_client_ui_reference --> pkg_session_reference + pkg_client_ui_reference --> pkg_typert_protocol pkg_client_ui_settings_general --> pkg_api_remotes pkg_client_ui_settings_general --> pkg_client_connection pkg_client_ui_settings_general --> pkg_client_locale @@ -1342,7 +1365,6 @@ flowchart TD pkg_client_ui_subagent --> pkg_client_locale pkg_client_ui_subagent --> pkg_client_runtime pkg_client_ui_subagent --> pkg_client_ui_conversation - pkg_client_ui_subagent --> pkg_client_ui_input_trigger pkg_client_ui_subagent --> pkg_client_ui_primitives pkg_client_ui_subagent --> pkg_client_ui_slots pkg_client_ui_subagent --> pkg_invariants @@ -1502,6 +1524,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1562,7 +1585,8 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | +| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | `extensions` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`tool-call-timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | @@ -1590,7 +1614,7 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`typert-registry`](../packages/typert/registry) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1626,6 +1650,7 @@ flowchart TD | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-slots`](../packages/client/ui-slots), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1635,7 +1660,7 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `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/runtime-diagnostics/invariants) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-plan`](../packages/client/ui-plan) | `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), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | -| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`client-ui-tool`](../packages/client/ui-tool) | `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), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`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/runtime-diagnostics/invariants) | diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index 535685927d..d354125b2a 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.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-reference.md -session-reference.md: 2380584e8bdc70ac1fee28849c8d09f69d55fd9d -session-reference.zh.md: 974723e95d6f7f68d45ddb8efd65db3fa9470fc4 +session-reference.md: e3ef2c00b7ddbeed9fa3f9d074df436d8c0cc7b5 +session-reference.zh.md: 74063c1525a1f7f67663e9b11148e18902b22748 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 2380584e8b..e3ef2c00b7 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -158,11 +158,11 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con @Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 974723e95d..74063c1525 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -158,11 +158,11 @@ async listCandidates( agent: Agent, query: string = '', limit: number = this.con @Remote('candidates') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f10329f0f2..5c0ca574bc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -3133,7 +3133,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.selectModel': return this.api.sessions.selectModel(request) case 'session.rename': return this.api.sessions.rename(request) case 'session.fork': return this.api.sessions.fork(request) - case 'session.prompt': return this.api.sessions.prompt(request, signal) + case 'session.prompt': return this.api.sessions.prompt(request) case 'session.attachment': return this.api.sessions.attachment(request) case 'session.updateQueue': return this.api.sessions.updateQueue(request) case 'session.cancel': return this.api.sessions.cancel(request) diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx index 0a52d552a4..f3343a183f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx @@ -1,5 +1,4 @@ import { memo, useMemo } from 'react' -import { sessionRecallLabels } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' @@ -22,19 +21,6 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ loadImage, fileMentions, useSession, renderSlot, t, }: ChatNodeSeatProps) { const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey)) - const referenceLabelsJson = useSession((snapshot) => { - const index = snapshot.chat.order.indexOf(nodeKey) - if (index <= 0) return '' - const previousKey = snapshot.chat.order[index - 1] - const previous = previousKey === undefined ? undefined : snapshot.chat.nodes.get(previousKey) - if (previous?.kind !== 'context') return '' - const labels = sessionRecallLabels((previous as ChatNode<'context'>).data.source) - return labels.length === 0 ? '' : JSON.stringify(labels) - }) - const referenceLabels = useMemo( - () => referenceLabelsJson === '' ? [] : JSON.parse(referenceLabelsJson) as string[], - [referenceLabelsJson], - ) const routedNode = node as ChatNode | undefined const owner = useMemo(() => node === undefined ? null @@ -46,8 +32,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ forkAt, loadImage, fileMentions, - ...(referenceLabels.length === 0 ? {} : { referenceLabels }), - }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, referenceLabels]) + }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions]) if (routedNode === undefined || owner === null) return null // Runtime dispatch owns the correlation: every Node's discriminant is the // keyed-slot entry passed alongside that same Node. TypeScript does not diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a9c7cd6d31..87c48c3b87 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -258,14 +258,14 @@ export function PendingSteeringBubble({ content, loadImage, t }: { /** User and admitted-steering keyed Chat renderer. */ export const UserMessageNodeView = memo(function UserMessageNodeView({ - node, loadImage, referenceLabels, t, + node, loadImage, t, }: ChatNodeViewProps<'user' | 'steering'>) { const data = node.data return ( ( Promise fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined - /** Session-reference labels owned by the immediately preceding recall node. */ - referenceLabels?: readonly string[] | undefined } /** Full props of one registered keyed Chat business renderer. */ diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts index 085127f9c5..3f9f2b5ef1 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -3,19 +3,29 @@ import type { ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { - contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, + contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent, sessionRecallLabels, } from '@deepseek-ai/dsh-client-runtime/client' import type { InboxState } from './inbox.ts' import { chatNode } from './common.ts' -type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode +interface ReferencedUserMessageNode extends UserMessageNode { + /** Labels cited by the immediately preceding session-reference context. */ + readonly referenceLabels?: readonly string[] +} + +interface ReferencedSteeringMessageNode extends SteeringMessageNode { + /** Labels cited by the immediately preceding session-reference context. */ + readonly referenceLabels?: readonly string[] +} + +type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | ContextMessageNode declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { /** Ordinary turn-opening user message. */ - user: UserMessageNode + user: ReferencedUserMessageNode /** User message admitted into an active turn. */ - steering: SteeringMessageNode + steering: ReferencedSteeringMessageNode /** Non-user context injected into model history. */ context: ContextMessageNode } @@ -51,6 +61,11 @@ export const messageDefinition: ConversationNodeDefinition = { } } const claimed = reader.previous('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true + const previous = reader.previous('input-message') + const labels = previous?.state.kind === 'context' && previous.state.seq + 1 === event.seq + ? sessionRecallLabels(previous.state.source) + : [] + const referenceLabels = labels.length === 0 ? {} : { referenceLabels: labels } return claimed ? { kind: 'steering', @@ -59,6 +74,7 @@ export const messageDefinition: ConversationNodeDefinition = { time: event.time, content: event.data.content, source: event.data.source, + ...referenceLabels, } : { kind: 'user', @@ -66,6 +82,7 @@ export const messageDefinition: ConversationNodeDefinition = { time: event.time, content: event.data.content, source: event.data.source, + ...referenceLabels, } }, update: context => context.state, diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx index a6308b07e9..f1e3163c43 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.client.spec.tsx @@ -61,9 +61,13 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps) anchorSeq: node.seq, location: { kind: 'session' }, visibility: 'visible', - data: node.kind === 'model-retry' ? { attempts: [node], current: node } : node, + data: node.kind === 'model-retry' + ? { attempts: [node], current: node } + : (node.kind === 'user' || node.kind === 'steering') && referenceLabels !== undefined + ? { ...node, referenceLabels } + : node, } - const props = { node: viewNode, t: translate, referenceLabels } as ChatNodeViewProps + const props = { node: viewNode, t: translate } as ChatNodeViewProps switch (node.kind) { case 'user': case 'steering': diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts index c6a33c2f76..a9de47c7f3 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts @@ -514,6 +514,31 @@ describe('built-in conversation node Definitions', () => { }) }) + it('associates session-reference labels inside the adjacent direct-message node', () => { + const referenceSource = { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [ + { sessionId: 'source-a', label: 'Research' }, + { sessionId: 'source-b', label: 'Review' }, + ], + } + const value = assembler([ + at(1, 'user/message', { + ...textMessage('reference-context', 'snapshot'), + source: referenceSource, + }, { surfaceOp: 'append' }), + at(2, 'user/message', textMessage('citing-user', '@Research and @Review'), { surfaceOp: 'append' }), + at(4, 'user/message', textMessage('later-user', 'unrelated'), { surfaceOp: 'append' }), + ]) + + const current = snapshot(value) + const users = [...current.nodes.values()].filter(candidate => candidate.kind === 'user') + expect(users[0]?.data).toMatchObject({ referenceLabels: ['Research', 'Review'] }) + expect(users[1]?.data).not.toHaveProperty('referenceLabels') + }) + it('keeps replacement copies out of Chat business nodes', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index a1c62f669c..a2ccb405e1 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-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 packages/client/ui-reference/README.md -README.md: c9ad377d2757a9dfea97c0660ca56cfb6beb6d20 -README.zh.md: 7e027abfc6237d7260fe00ff82af9a7103281804 +README.md: 12a8e69624c0d7fe28c10ae708466fdda4a8480d +README.zh.md: bfaee51af3947fe794afab3a99df194c543b0082 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index c9ad377d27..12a8e69624 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -6,7 +6,7 @@ Unified Web `@file` and `@session` source. The browser starts the `fileReference File picks insert the natural text defined by the shared `@path` grammar. A file closes completion and adds a trailing space; a directory keeps the menu active at its trailing slash so the user can descend another level. Paths containing whitespace use `@"path with spaces"`, and a quote the user opened explicitly remains quoted. -Session picks insert an atomic composer chip whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. The visible chip uses `@label`; serialization never reconstructs identity from that label. Ordinary send delegates the canonical mention to `session.prompt`, where Host-side session-reference preparation owns validation, snapshotting, and model context. +Session picks insert an atomic composer chip whose hidden `ref` and clipboard representation are the canonical `@[label](dsh-session:…)` mention returned by the Host. The visible chip uses `@label`; serialization never reconstructs identity from that label. Ordinary send carries the canonical mention through `session.prompt`; the session-reference service validates it and captures model context at `agent/pre-step`. The `/client` export is the plugin body (`apply`/`inject`) only; candidate encoding stays internal to the registration effect. @@ -20,6 +20,6 @@ Candidate browsing has no model effect. A selected file or session changes only ## Known Limitations and Deferred Work -- **Candidate failure is intentionally quiet** — one unavailable or failed Remote discovery call yields no rows for that domain, while prompt submission still reports session-reference preparation failures through the ordinary send path. +- **Candidate failure is intentionally quiet** — one unavailable or failed Remote discovery call yields no rows for that domain. A session-reference preparation failure occurs after prompt acceptance and terminates that agent turn. - **No browser-side file scan** — Web completion requires a mounted Host `ctx.fileReferences` provider; the browser cannot fall back to its own filesystem. - **Session search remains metadata-only** — discovery filters session id, cwd, and the latest log-backed title through `ctx.sessionReferenceResolver`; message bodies and full transcripts are not searched. diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index 7e027abfc6..bfaee51af3 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -6,7 +6,7 @@ 选择文件会插入共享 `@path` 语法所定义的自然文本。文件会关闭补全并追加一个尾随空格;目录则让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。包含空白的路径使用 `@"path with spaces"`,用户显式打开的引号会继续保留。 -选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` 提及标记。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会把规范提及标记交给 `session.prompt`,由宿主侧的会话引用准备负责校验、生成快照和模型上下文。 +选择会话会插入一个原子的输入框 chip,其隐藏 `ref` 与剪贴板表示均为宿主返回的规范 `@[label](dsh-session:…)` mention。可见 chip 使用 `@label`;序列化永远不会根据该标签重建身份。普通发送会通过 `session.prompt` 携带规范 mention,session-reference 服务会在 `agent/pre-step` 校验它并捕获模型上下文。 `/client` 只导出插件主体(`apply`/`inject`);候选编码保留在注册 effect 内部。 @@ -20,6 +20,6 @@ ## 已知限制与暂缓事项 -- **候选失败有意保持静默**:Remote 发现调用不可用或失败时,该领域不产生候选行;提示词提交仍会通过普通发送路径报告会话引用准备失败。 +- **候选失败有意保持静默**:Remote 发现调用不可用或失败时,该领域不产生候选行。会话引用准备失败发生在提示词接受后,并会终止该 agent 轮次。 - **浏览器侧不扫描文件**:Web 补全需要挂载宿主 `ctx.fileReferences` 提供方;浏览器无法回退到自身文件系统。 - **会话搜索仍仅使用元数据**:发现流程通过 `ctx.sessionReferenceResolver` 筛选 session id、cwd 和以日志为依据的最新标题;不搜索消息主体或完整 transcript(文本记录)。 diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index f838083f63..fb356e73a8 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -9,31 +9,15 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' -import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, InputTriggerServiceContract, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' -import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { en, NS, zh, type ReferenceKey } from './locales.ts' -/** - * The two Remote calls this source needs. The generated face wraps every - * business result in {@link RemoteResult}: a carrier failure arrives as the - * `ok: false` branch rather than a rejection, so discovery reads one envelope - * per domain and either domain can fail without hiding the other. - */ -interface ReferenceRemotes { - readonly fileReferences: { - list: (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> - } - readonly sessionReferenceResolver: { - candidates: (agentId: SessionId, query: string, signal?: AbortSignal) => Promise> - } -} - /** Required services: the trigger registry, the Remote namespaces, and the copy. */ export const inject = [ 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', @@ -46,18 +30,17 @@ export const inject = [ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') const t = ctx.locale.bind(NS) - const remote: ReferenceRemotes = ctx.remote const source: InputTriggerSource = { trigger: '@', name: 'reference', async candidates(session: ClientSessionContext, { query, quoted, signal }) { - const files = remote.fileReferences.list(session.sessionId, query, signal).then( + const files = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( result => result.ok ? result.value : [], () => [], ) const sessions = quoted === true ? Promise.resolve([] as SessionReferenceMentionCandidate[]) - : remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( + : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( result => result.ok ? result.value : [], () => [], ) diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 5a56be0add..489b884d57 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-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 packages/context/session-reference/README.md -README.md: 706838bfd475c320b589e091467b2974c9cc43ab -README.zh.md: 71e5fdca0755ea481b19aec79ad1e5ec9cf067fb +README.md: 13dfcf2fa99a118efa03f4f183b6b426bf704c2b +README.zh.md: a81f8c62942ca9a9b3b10c1c109c1fb227971ec0 diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 706838bfd4..13dfcf2fa9 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -7,14 +7,14 @@ English | [中文](README.zh.md) ## Public API - `listCandidates(agent, query?, limit?)` lists sessions other than `agent.id`, filters case-insensitively by id, cwd, or the latest log-backed title, and ranks same-cwd, cwd-less, then other-cwd records while preserving `listSessions()` creation order within each group. Each selected candidate uses that title as the mention label and falls back to the session id when the title is absent or unreadable; message bodies are not searched. The unary `sessionReferenceResolver/candidates` Remote method serves the same discovery under the configured candidate limit and attaches each candidate's canonical mention, so browser consumers call `ctx.remote.sessionReferenceResolver.candidates` without an API Proxy route. -- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated, identified `UserMessage` context. Any invalid reference, failed read, cancellation, or budget failure rejects before the host calls `followup()` or `steer()`. +- `prepare(agent, content, references, signal?)` preserves first-mention order, deduplicates ids, rejects self-reference and more than the configured distinct-source limit, reads every source in parallel, and returns detached content plus zero or one aggregated, identified `UserMessage` context. The service calls it for canonical mentions in direct user messages after downstream `agent/pre-step` listeners accept the step. - `encodeSessionReferenceUri()` and `decodeSessionReferenceUri()` implement `dsh-session:` so every JavaScript string id round-trips exactly. `formatSessionReferenceMention()` emits `@[label](uri)`, and `parseSessionReferenceText()` replaces Markdown mentions or bare canonical URIs with readable `@label` text while returning structured references. Explicit Markdown mentions reject every malformed URI; bare text is considered a reference only when a non-empty base64url-shaped payload follows the scheme, and a matching noncanonical candidate still fails. Empty or punctuation-only scheme mentions remain ordinary discussion text. ## Snapshot semantics -Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compaction` source marker from the folded current surface. Separately sourced session-reference messages are injected context and are excluded, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, other plugin-generated user messages except marked compact checkpoints, and unfinished assistant chunks are also excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. +Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source when the target message reaches `agent/pre-step`. A queued message therefore captures the source state at model-step entry, and the resulting context is immutable after that point. Projection keeps only direct-user `user/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compaction` source marker from the folded current surface. Separately sourced session-reference messages are injected context and are excluded, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, other plugin-generated user messages except marked compact checkpoints, and unfinished assistant chunks are also excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text. -The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The Web host installs a one-shot `agent/pre-step` listener keyed by the prepared direct message id before delivery. An `enter` decision containing that exact id receives the snapshot immediately before the direct message; an ordinary discard removes the listener, and queue-to-steer relocation preserves the association. The target log therefore records a sourced context `user/message` followed by the readable direct `user/message`. Later source mutation, compaction, or deletion cannot change target replay. +The context source is `{ kind: 'session-reference', version: 1, references }`; each reference records its source id and label, capture seq, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. The service's outer `agent/pre-step` listener post-processes accepted direct user messages, preserves their message ids, and inserts each snapshot immediately before the message that cited it. Queue edits and queue-to-steer relocation need no reference-specific handling because parsing occurs after the final inbox claim. Invalid mentions, failed reads, cancellation, and budget failures end that turn before its messages enter model-visible history. The target log records a sourced context `user/message` followed by the readable direct `user/message`; source mutation after capture cannot change target replay. ## Configuration diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index 71e5fdca07..a81f8c6294 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -7,14 +7,14 @@ ## 公开 API - `listCandidates(agent, query?, limit?)` 会列出 `agent.id` 之外的会话,按 id、cwd 或以日志为依据的最新标题进行不区分大小写的筛选,再按同 cwd、无 cwd、其他 cwd 记录排序,同时保持每组内的 `listSessions()` 创建顺序。每个已选候选会话都使用该标题作为 mention label;标题不存在或无法读取时回退到会话 id。不搜索消息主体。一元 `sessionReferenceResolver/candidates` Remote 方法在配置的候选上限内提供同一发现能力,并为每个候选附上规范 mention,浏览器消费方直接调用 `ctx.remote.sessionReferenceResolver.candidates`,无需 API Proxy 路由。 -- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合且带标识的 `UserMessage` 上下文。任何无效引用、读取失败、取消或预算失败,都会使准备操作在宿主调用 `followup()` 或 `steer()` 之前失败。 +- `prepare(agent, content, references, signal?)` 会保留首次 mention 顺序、对 id 去重,并拒绝自引用或超过已配置不同源上限的情况。它会并行读取所有源,返回与输入脱离的内容,外加零个或一个聚合且带标识的 `UserMessage` 上下文。下游 `agent/pre-step` 监听器接受步骤后,该服务会针对直接用户消息中的规范 mention 调用此方法。 - `encodeSessionReferenceUri()` 与 `decodeSessionReferenceUri()` 实现 `dsh-session:`,因此每个 JavaScript 字符串 id 都能精确往返。`formatSessionReferenceMention()` 发出 `@[label](uri)`,`parseSessionReferenceText()` 将 Markdown mention 或裸规范 URI 替换为可读的 `@label` 文本,并返回结构化引用。解析器会拒绝显式 Markdown mention 中任何格式错误的 URI;只当 scheme 后跟非空、符合 base64url 形状的 payload 时,裸文本才被视为引用,匹配但非规范的候选项仍会失败。空 scheme mention 或只含标点符号的 scheme mention 仍是普通讨论文本。 ## 快照语义 -准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`,入队后绝不重读。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compaction` 源标记。带独立来源的 session-reference 消息属于注入上下文,会被排除以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、除已标记 compact 检查点外的其他插件生成 user 消息,以及未完成的 assistant 分片也都会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。 +目标消息到达 `agent/pre-step` 时,准备阶段会对每个不同源调用一次 `ctx.sessionQuery.readSurface()`。因此,queued 消息在进入模型步骤时捕获源状态,此后生成的上下文保持不变。它仅投影折叠后当前表层中的用户直接发出的 `user/message`、assistant 文本,以及 `user/message` 检查点;这类检查点携带规范 `dsh-compaction` 源标记。带独立来源的 session-reference 消息属于注入上下文,会被排除以防止快照递归传播。已遮蔽的压缩(compaction)前事件、工具、推理(reasoning)、除已标记 compact 检查点外的其他插件生成 user 消息,以及未完成的 assistant 分片也都会被排除。因此,已压缩源只会提供最新检查点及其后保留的会话内容,不会还原已遮蔽的文本。 -上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。Web 宿主会在交付前安装一个以已准备直接消息 id 为键的一次性 `agent/pre-step` 监听器。包含该确切 id 的 `enter` 决策会在直接消息之前紧邻插入快照;普通丢弃会移除监听器,而从 queue 移动到 steer 会保留两者的关联。目标日志因此会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message`。后续源变更、压缩或删除都无法改变目标回放。 +上下文源为 `{ kind: 'session-reference', version: 1, references }`;每条引用会记录其源 id 与 label、捕获 seq、是否存在 compact、已保留/已省略消息数、已省略 UTF-8 字节数与截断状态。该服务的外层 `agent/pre-step` 监听器会处理已接受的直接用户消息,保留其消息 id,并把每份快照插入到引用它的消息紧前。解析发生在最终领取收件箱消息之后,因此队列编辑和从 queue 移动到 steer 不需要引用专用处理。无效 mention、读取失败、取消和预算失败会在消息进入面向模型的历史之前结束该轮次。目标日志会先记录一条带来源信息的上下文 `user/message`,再记录可读的直接 `user/message`;捕获后的源变更无法改变目标回放。 ## 配置 diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index b77ba8ce16..8110f4ead6 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -7,9 +7,9 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionSurfaceSnapshot, SessionTitleObservationResult } from '@deepseek-ai/dsh-session-query' @@ -26,7 +26,7 @@ import type { PreparedReferencedMessage, SessionReferenceCandidate, SessionReferenceInput, SessionReferenceMentionCandidate, SessionReferenceSource, } from './types.ts' -import { formatSessionReferenceMention } from './uri.ts' +import { formatSessionReferenceMention, parseSessionReferenceText } from './uri.ts' export type * from './types.ts' export type { Config, SessionReferenceErrorCode } from './config.ts' @@ -103,6 +103,46 @@ export class SessionReferenceResolver extends TypertRemoteService { 'SESSION_REFERENCE_INVALID_CONFIG', ) } + ctx.on('agent/pre-step', async ({ agent, signal }, next): Promise => { + const decision = await next() + if (decision.kind === 'reject') return decision + return { + kind: 'enter', + messages: await this.prepareDirectMessages(agent, decision.messages, signal), + } + }, { prepend: true }) + } + + /** + * Replace canonical mentions in direct user messages and place each prepared + * snapshot immediately before the message that cited it. + * @param agent - agent entering the model step. + * @param messages - messages accepted by downstream pre-step listeners. + * @param signal - active turn cancellation. + * @returns messages with session-reference context inserted in citation order. + */ + private async prepareDirectMessages( + agent: Agent, + messages: readonly UserMessage[], + signal: AbortSignal, + ): Promise { + const prepared = await Promise.all(messages.map(async (message): Promise => { + if (message.source.kind !== 'user') return [message] + const references: SessionReferenceInput[] = [] + const content = message.content.map((block): ContentBlock => { + if (block.type !== 'text') return block + const parsed = parseSessionReferenceText(block.text) + references.push(...parsed.references) + return { type: 'text', text: parsed.text } + }) + if (references.length === 0) return [message] + const resolved = await this.prepare(agent, content, references, signal) + const direct = freezeMessage({ ...message, content: resolved.content }) + return resolved.additionalContext === undefined + ? [direct] + : [resolved.additionalContext, direct] + })) + return prepared.flat() } /** @@ -186,11 +226,11 @@ export class SessionReferenceResolver extends TypertRemoteService { } /** - * Snapshot all references before enqueue and return one aggregated durable context. + * Snapshot all references for one accepted direct message and return one aggregated durable context. * @param agent - target agent; references to it are rejected. * @param content - already host-normalized readable message content. * @param references - structured source sessions in mention order. - * @param signal - optional cancellation boundary for host request teardown. + * @param signal - optional cancellation boundary for the active turn. * @returns detached content and optional referenced-session context. */ async prepare( diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index d08b7da413..357e8bb0a6 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -301,6 +301,79 @@ describe('session reference discovery and preparation', () => { }]) }) + it('prepares direct mentions at pre-step and keeps ordinary and plugin messages unchanged', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target')) + const source = ctx.sessions.create(SessionId('source')) + source.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'source fact' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const agent = fakeAgent(target) + const direct = createUserMessage({ + content: [{ + type: 'text', + text: `compare ${formatSessionReferenceMention({ sessionId: source.id, label: 'Research' })} now`, + }], + source: { kind: 'user' }, + }) + const ordinary = createUserMessage({ + content: [{ type: 'text', text: 'ordinary prompt' }], + source: { kind: 'user' }, + }) + const plugin = createUserMessage({ + content: [{ type: 'text', text: formatSessionReferenceMention({ sessionId: source.id, label: 'Ignored' }) }], + source: { kind: 'plugin', plugin: 'test' }, + }) + const signal = new AbortController().signal + + const decision = await agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [direct, ordinary, plugin], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [direct, ordinary, plugin] }), + ) + + expect(decision.kind).toBe('enter') + if (decision.kind !== 'enter') throw new Error('expected entered pre-step') + expect(decision.messages).toHaveLength(4) + expect(decision.messages[0]?.source).toMatchObject({ + kind: 'session-reference', + references: [{ sessionId: source.id, label: 'Research' }], + }) + expect(decision.messages[1]).toMatchObject({ + id: direct.id, + content: [{ type: 'text', text: 'compare @Research now' }], + }) + expect(decision.messages[1]).not.toBe(direct) + expect(decision.messages[2]).toBe(ordinary) + expect(decision.messages[3]).toBe(plugin) + }) + + it('does not prepare a rejected pre-step and rejects malformed direct mentions', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target')) + const agent = fakeAgent(target) + const malformed = createUserMessage({ + content: [{ type: 'text', text: '@[bad](dsh-session:not-canonical)' }], + source: { kind: 'user' }, + }) + const readSurface = vi.spyOn(ctx.sessionQuery, 'readSurface') + const signal = new AbortController().signal + + await expect(agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [malformed], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'reject' as const }), + )).resolves.toEqual({ kind: 'reject' }) + expect(readSurface).not.toHaveBeenCalled() + + await expect(agentEvents(ctx, agent).waterfall( + 'agent/pre-step', + { messages: [malformed], turn: 1, step: 1, signal }, + () => Promise.resolve({ kind: 'enter' as const, messages: [malformed] }), + )).rejects.toThrow(/invalid session reference URI/) + }) + it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target')) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4e76ca745c..dbd3731614 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -216,7 +216,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to one keyed Chat business renderer. */\nexport interface ChatNodeOwnerProps {\n /** Selected Tool call, when the shared details store names one. */\n selectedCallId?: CallId | undefined\n /** Session workspace root; Tool summaries display paths relative to it. */\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: CallId) => void\n forkAt: (seq: number) => void\n /** Resolve a session-authorized historical image for inline display. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Session-reference labels owned by the immediately preceding recall node. */\n referenceLabels?: readonly string[] | undefined\n}', + '/** Stable owner currency delivered to one keyed Chat business renderer. */\nexport interface ChatNodeOwnerProps {\n /** Selected Tool call, when the shared details store names one. */\n selectedCallId?: CallId | undefined\n /** Session workspace root; Tool summaries display paths relative to it. */\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: CallId) => void\n forkAt: (seq: number) => void\n /** Resolve a session-authorized historical image for inline display. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n}', ], ownerPropsReferences: [ 'ImageAttachmentRef', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 29f47d5ecd..ad7d0dbb62 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1289,8 +1289,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async prepare( agent: Agent, content: ContentBlock[], references: SessionReferenceInput[], signal?: AbortSignal, ): Promise', - description: 'Snapshot all references before enqueue and return one aggregated durable context.', - parameters: [{ name: 'agent', description: 'target agent; references to it are rejected.' }, { name: 'content', description: 'already host-normalized readable message content.' }, { name: 'references', description: 'structured source sessions in mention order.' }, { name: 'signal', description: 'optional cancellation boundary for host request teardown.' }], + description: 'Snapshot all references for one accepted direct message and return one aggregated durable context.', + parameters: [{ name: 'agent', description: 'target agent; references to it are rejected.' }, { name: 'content', description: 'already host-normalized readable message content.' }, { name: 'references', description: 'structured source sessions in mention order.' }, { name: 'signal', description: 'optional cancellation boundary for the active turn.' }], returns: 'detached content and optional referenced-session context.', }, ], diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 22fb19c837..5826f75f75 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: 69f10f1ed22d95e7c4601754957218df374a66cc -README.zh.md: 40179d51f8024105beafdc7c49e6eb8e70c7a224 +README.md: 607cd4e4176631b64daf4a298b5d86a75ccdce68 +README.zh.md: cdfb5aa65b3ad5b00596487aae6c99e2f9d4e433 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 69f10f1ed2..607cd4e417 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -38,8 +38,6 @@ Session model selection is a session-domain contract. `session.models` returns t `session.prompt` and `subagent.prompt` accept optional request-local `clientTimeZone` provenance. When present, the Host validates and canonicalizes `UTC` or an IANA Area/Location before Agent entry, rejects invalid input with `invalid-time-zone`, and records the canonical value on that exact `user-rpc` message beside its `rpcId`. The value is not Session, connection, create, resume, or fork state; non-browser callers may omit it. -`session.prompt` parses canonical session mentions from normalized text blocks and asks `ctx.sessionReferenceResolver` to prepare every snapshot before enqueue. Parsing, cancellation, validation, reads, and budget enforcement are one admission transaction: failure enqueues no message, while success registers the separately sourced context against the readable prompt's exact message id for insertion immediately before it at pre-step entry. Ordinary discard removes the registration, and moving a queued prompt to steering preserves the association. - 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 jobs ride the same live-push posture. When `ctx.jobs` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/jobs` 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 `JobView` 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. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 40179d51f8..cdfb5aa65b 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -38,8 +38,6 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.prompt` 和 `subagent.prompt` 接受可选的请求本地 `clientTimeZone` 来源信息。若提供该值,Host 会在进入 Agent 前校验 `UTC` 或 IANA Area/Location 并将其规范化;无效输入以 `invalid-time-zone` 拒绝,规范值则与 `rpcId` 一起记录在这条确切的 `user-rpc` 消息上。该值不属于 Session、连接、create、resume 或 fork 状态;非浏览器调用方可以省略它。 -`session.prompt` 从规范化文本块中解析规范会话提及标记,并要求 `ctx.sessionReferenceResolver` 在入队前准备每个快照。解析、取消、校验、读取和预算约束共同构成一个准入事务:失败时不会有消息入队;成功时,会以可读提示词的确切消息 id 注册独立来源上下文,等待 pre-step 进入时紧邻插入到它之前。普通丢弃会移除该注册,把 queued 提示词移动到 steering(中途引导)时仍会保留两者的关联。 - 待处理的 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()` 修改队列。认领操作的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 后台任务沿用同一种实时推送姿态。当组合中有 `ctx.jobs` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/jobs` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `JobView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 8c8bd1e05b..946e27ded3 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -58,7 +58,6 @@ "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", @@ -89,6 +88,7 @@ "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^" + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8a8b3a8506..c675b60907 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -8,7 +8,7 @@ import { mkdir, stat } from 'node:fs/promises' import { dirname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' -import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus, PreStepDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-presets/types' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' @@ -18,10 +18,6 @@ import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session' import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { - parseSessionReferenceText, - type SessionReferenceInput, -} from '@deepseek-ai/dsh-session-reference' import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { SubagentError } from '@deepseek-ai/dsh-subagent' import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent' @@ -86,7 +82,7 @@ import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@dee import { credentialRef } from '@deepseek-ai/dsh-credentials' // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`. import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' -import type { CallId, MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' // Side-effect type import: resolves the `approval/request` waterfall and @@ -165,21 +161,6 @@ async function durablePromptContent(ctx: Context, content: readonly PromptConten return blocks } -/** Remove canonical session mentions from text blocks and retain their structured identities. */ -function parseReferencedContent(content: readonly PromptContentPart[]): { - content: PromptContentPart[] - references: SessionReferenceInput[] -} { - const references: SessionReferenceInput[] = [] - const normalized = content.map((part): PromptContentPart => { - if (part.type !== 'text') return part - const parsed = parseSessionReferenceText(part.text) - references.push(...parsed.references) - return { type: 'text', text: parsed.text } - }) - return { content: normalized, references } -} - /** Search durable content for an image reference, including nested tool results. */ function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { if (!Array.isArray(content)) return undefined @@ -1085,72 +1066,6 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie } } -/** One ApiProxy instance's pending reference-prompt admission listeners. */ -interface PreparedPromptOwnership { - readonly relocating: Set - readonly cleanups: Map void> -} - -/** Deliver a prepared prompt and inject its snapshot immediately before that exact message enters. */ -function deliverPrompt( - ctx: Context, - agent: Agent, - mode: 'queue' | 'steer', - message: UserMessage, - additionalContext: UserMessage | undefined, - ownership: PreparedPromptOwnership, -): void { - if (additionalContext === undefined) { - if (mode === 'steer') agent.steer(message) - else agent.followup(message) - return - } - let cleanedUp = false - let detachPreStep = (): void => {} - let detachDiscard = (): void => {} - let detachDisposed = (): void => {} - const cleanup = (): void => { - /* v8 ignore next -- all settlement paths share this idempotent release. */ - if (cleanedUp) return - cleanedUp = true - ownership.cleanups.delete(message.id) - detachPreStep() - detachDiscard() - detachDisposed() - } - ownership.cleanups.set(message.id, cleanup) - // An agent retired with the prepared prompt still pending must not leave - // these listeners on the Host root context for the process lifetime. - detachDisposed = ctx.on('agent/disposed', ({ agent: subject }) => { - if (subject === agent) cleanup() - }) - detachPreStep = ctx.on('agent/pre-step', async ({ agent: subject, messages }, next): Promise => { - if (subject !== agent || !messages.some(candidate => candidate.id === message.id)) return next() - cleanup() - const decision = await next() - if (decision.kind !== 'enter') return decision - const promptIndex = decision.messages.findIndex(candidate => candidate.id === message.id) - if (promptIndex < 0) return decision - return { - kind: 'enter', - messages: decision.messages.toSpliced(promptIndex, 0, additionalContext), - } - }, { prepend: true }) - detachDiscard = ctx.on('agent/inbox/discarded', ({ agent: subject, message: discarded }) => { - if (subject !== agent || discarded.id !== message.id || ownership.relocating.has(message.id)) return - const remainsPending = [...agent.inbox.nextTurn, ...agent.inbox.nextStep] - .some(candidate => candidate.id === message.id) - if (!remainsPending) cleanup() - }) - try { - if (mode === 'steer') agent.steer(message) - else agent.followup(message) - } catch (error: unknown) { - cleanup() - throw error - } -} - /** * Implement ApiProxy over a composed host context. * @param ctx - a context with the Host spine and Workspace registry mounted. @@ -1185,10 +1100,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const pendingApprovals = new Map() const muxQueues = new Set>>() const imageAdmissionChains = new WeakMap>() - const preparedPromptOwnership: PreparedPromptOwnership = { - relocating: new Set(), - cleanups: new Map(), - } /** Serialize image admission with model selection for one agent. */ function serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { @@ -2487,7 +2398,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { sessionId: childId }) }, - async prompt(request, signal) { + async prompt(request) { const { sessionId, mode, content, clientTimeZone } = request.payload const canonicalTimeZone = clientTimeZone === undefined ? undefined @@ -2502,32 +2413,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId) if ('refused' in resolved) return resolved.refused const agent = resolved.agent - let parsed: ReturnType - try { - parsed = parseReferencedContent(content) - } catch (error: unknown) { - return err(request, { - code: 'reference-invalid', - message: 'invalid session reference', - details: { reason: String(error) }, - }) - } // Request identity and optional browser zone ride the exact durable user message. const source: MessageSource = { kind: 'user', rpcId: request.rpcId, ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), } - const hasImage = parsed.content.some(part => part.type === 'image') + const hasImage = content.some(part => part.type === 'image') const admit = async (): Promise> => { try { - if (signal?.aborted === true) { - return err(request, { - code: 'cancelled', - message: 'prompt submission was aborted', - details: {}, - }) - } if (hasImage) { const current = selectionFor(agent).current const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model) @@ -2539,45 +2433,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } } - let durable = await durablePromptContent(ctx, parsed.content) - let additionalContext: UserMessage | undefined - if (parsed.references.length > 0) { - const sessionReferences = ctx.get('sessionReferenceResolver') - if (sessionReferences === undefined) { - return err(request, { - code: 'reference-unavailable', - message: 'session reference capability unavailable', - details: { kind: 'session' }, - }) - } - try { - const prepared = await sessionReferences.prepare(agent, durable, parsed.references, signal) - durable = prepared.content - additionalContext = prepared.additionalContext - } catch (error: unknown) { - if (signal !== undefined && isAborted(signal)) { - return err(request, { - code: 'cancelled', - message: 'session reference preparation was aborted', - details: {}, - }) - } - return err(request, { - code: 'reference-failed', - message: 'session reference preparation failed', - details: { reason: String(error) }, - }) - } - } - if (signal !== undefined && isAborted(signal)) { - return err(request, { - code: 'cancelled', - message: 'prompt submission was aborted', - details: {}, - }) - } + const durable = await durablePromptContent(ctx, content) const message: UserMessage = createUserMessage({ content: durable, source }) - deliverPrompt(ctx, agent, mode, message, additionalContext, preparedPromptOwnership) + if (mode === 'steer') agent.steer(message) + else agent.followup(message) } catch (error: unknown) { if (error instanceof AttachmentError) { return err(request, { @@ -2690,16 +2549,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (action.kind === 'edit') { agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content })) } else { - if (action.kind === 'steer') preparedPromptOwnership.relocating.add(itemId) - try { - agent.inbox.remove(itemId) - if (action.kind === 'steer') agent.steer(message) - } catch (error: unknown) { - preparedPromptOwnership.cleanups.get(itemId)?.() - throw error - } finally { - preparedPromptOwnership.relocating.delete(itemId) - } + agent.inbox.remove(itemId) + if (action.kind === 'steer') agent.steer(message) } return Promise.resolve(ok(request, { accepted: true as const })) }, diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 7c2d80fcef..03cfdd1e15 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -53,9 +53,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('reference-unavailable'), message: z.string(), details: z.object({ kind: z.union([z.literal('file'), z.literal('session')]) }) }), - z.object({ code: z.literal('reference-invalid'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('reference-failed'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 05431eca08..0b5506b6b6 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -51,9 +51,6 @@ export interface RpcErrorDetailsMap { 'agent-preset-not-found': { agentPreset: string; available: string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } - 'reference-unavailable': { kind: 'file' | 'session' } - 'reference-invalid': { reason: string } - 'reference-failed': { reason: string } 'attachment-error': { reason: string } 'queue-item-not-found': { itemId: MessageId } 'steer-unavailable': { itemId: MessageId } diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 24f91bf552..2e7c9b22f4 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -349,7 +349,7 @@ export interface SessionsApi { mode: 'queue' | 'steer' content: PromptContentPart[] clientTimeZone?: string - }>, signal?: AbortSignal): + }>): Promise> /** Reads one durable image after proving that this session's log references its id. */ diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d33e8dc55b..697171ec53 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -96,7 +96,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) }, 'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) }, 'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) }, - 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r, signal) => api.sessions.prompt(r, signal) }, + 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, 'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) }, 'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, @@ -172,6 +172,9 @@ function fullResponse(narrow: RpcResponse): Response { * Wire<> widening back to the exact payload (undefined-valued properties and * absent ones are indistinguishable after JSON transport). */ +// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own +// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection. +// oxlint-disable-next-line typescript/no-unnecessary-type-parameters async function handleUnary( api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal, ): Promise { diff --git a/packages/host/apiproxy/tests/api-proxy-references.spec.ts b/packages/host/apiproxy/tests/api-proxy-references.spec.ts deleted file mode 100644 index 921440d7f2..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-references.spec.ts +++ /dev/null @@ -1,412 +0,0 @@ -/** - * Referenced prompt coverage over the real ApiProxy: atomic snapshot - * preparation before enqueue and error/cancellation behavior. Discovery lives - * on the owning services' Remote faces, tested in their packages. - */ -import { Context } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' -import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry from '@deepseek-ai/dsh-tools' -import UserQuestionService from '@deepseek-ai/dsh-user-questions' -import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' -import { RpcId } from '../src/api/rpc.ts' -import { createApiProxy } from '../src/api-proxy.ts' - -const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } -let nextRpc = 1 - -function request

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`reference-${String(nextRpc++)}`), payload } -} - -function expectOk(response: RpcResponse): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -function expectErr(response: RpcResponse): { code: string; message: string } { - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - return response.result.error -} - -async function harness(): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRegistry) - await ctx.plugin(UserQuestionService) - await ctx.plugin(AgentRegistry) - ctx.provide('workspace', { list: () => [] } as never) - return ctx -} - -function stubAgent(ctx: Context, status: Agent['status'] = 'idle') { - const session = ctx.sessions.create(undefined, { meta: { cwd: '/project' } }) - const followup = vi.fn() - const steer = vi.fn() - const inject = vi.fn() - const inbox = new Inbox(session, { - inserted(message) { - agentEvents(ctx, agent).emit('agent/inbox/inserted', { message }) - }, - discarded(message) { - agentEvents(ctx, agent).emit('agent/inbox/discarded', { message }) - }, - claimed(message, turn) { - agentEvents(ctx, agent).emit('agent/inbox/claimed', { message, turn }) - }, - }) - const agent = { - id: session.id, - session, - inbox, - status, - ctx, - followup, - steer, - inject, - cancel: vi.fn(), - } as unknown as Agent & { - followup: typeof followup - steer: typeof steer - inject: typeof inject - } - followup.mockImplementation((message) => { inbox.append('next-turn', message) }) - steer.mockImplementation((message) => { inbox.append('next-step', message) }) - inject.mockImplementation((message) => { inbox.append('next-step', message) }) - ctx.agents.register(agent) - return agent -} - -describe('referenced prompt preparation', () => { - it('normalizes the visible mention and waits for all context preparation before enqueue', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const earlier = createUserMessage({ - source: { kind: 'user' }, - content: [{ type: 'text', text: 'earlier queued prompt' }], - }) - agent.followup(earlier) - const source = 'source-session' as SessionId - const mention = formatSessionReferenceMention({ sessionId: source, label: 'Research' }) - let finish!: () => void - const context = createUserMessage({ - source: { - kind: 'session-reference' as const, - form: 'recall' as const, - version: 1 as const, - references: [{ - sessionId: source, - label: 'Research', - capturedThroughSeq: null, - compacted: false, - originalMessages: 1, - retainedMessages: 1, - omittedMessages: 0, - omittedBytes: 0, - truncated: false, - inputIndex: 0, - }], - }, - content: [{ type: 'text' as const, text: 'snapshot' }], - }) - const prepare = vi.fn(() => new Promise<{ - content: { type: 'text'; text: string }[] - additionalContext: typeof context - }>((resolve) => { - finish = () => { - resolve({ - content: [{ type: 'text', text: 'compare @Research now' }], - additionalContext: context, - }) - } - })) - ctx.provide('sessionReferenceResolver', { prepare } as never) - const api = createApiProxy(ctx, DEFAULTS) - const signal = new AbortController().signal - const pending = api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ type: 'text' as const, text: `compare ${mention} now` }], - mode: 'queue' as const, - }), signal) - await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() }) - expect(agent.followup).toHaveBeenCalledTimes(1) - expect(prepare).toHaveBeenCalledWith( - agent, - [{ type: 'text', text: 'compare @Research now' }], - [{ sessionId: source, label: 'Research' }], - signal, - ) - finish() - expect(expectOk(await pending)).toEqual({ accepted: true }) - expect(agent.followup).toHaveBeenCalledTimes(2) - const sent = agent.followup.mock.calls[1]?.[0] - expect(sent).toMatchObject({ - content: [{ type: 'text', text: 'compare @Research now' }], - source: { kind: 'user' }, - }) - if (sent === undefined) throw new Error('expected queued prompt') - const firstBatch = agent.inbox.claim('next-turn', 1) - const firstDecision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: firstBatch, turn: 1, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: firstBatch }), - ) - expect(firstDecision).toEqual({ kind: 'enter', messages: [earlier] }) - const referencedBatch = agent.inbox.claim('next-turn', 2) - const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: referencedBatch, turn: 2, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: referencedBatch }), - ) - expect(decision).toEqual({ kind: 'enter', messages: [context, sent] }) - const replay = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: referencedBatch, turn: 2, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: referencedBatch }), - ) - expect(replay).toEqual({ kind: 'enter', messages: referencedBatch }) - }) - - it('inserts prepared session context immediately before steering at admission', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const source = 'source-session' as SessionId - const context = createUserMessage({ - source: { - kind: 'session-reference' as const, - form: 'recall' as const, - version: 1 as const, - references: [{ - sessionId: source, - label: 'Research', - capturedThroughSeq: null, - compacted: false, - originalMessages: 1, - retainedMessages: 1, - omittedMessages: 0, - omittedBytes: 0, - truncated: false, - inputIndex: 0, - }], - }, - content: [{ type: 'text' as const, text: 'snapshot' }], - }) - ctx.provide('sessionReferenceResolver', { - prepare: () => Promise.resolve({ - content: [{ type: 'text' as const, text: 'continue @Research' }], - additionalContext: context, - }), - } as never) - const api = createApiProxy(ctx, DEFAULTS) - const response = await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ - type: 'text' as const, - text: `continue ${formatSessionReferenceMention({ sessionId: source, label: 'Research' })}`, - }], - mode: 'steer' as const, - })) - expect(expectOk(response)).toEqual({ accepted: true }) - const steered = agent.steer.mock.calls[0]?.[0] - expect(steered?.content).toEqual([{ type: 'text', text: 'continue @Research' }]) - expect(steered?.source.kind).toBe('user') - if (steered === undefined) throw new Error('expected steering prompt') - const signal = new AbortController().signal - const batch = agent.inbox.claim('next-step', 1) - const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: batch, turn: 1, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: batch }), - ) - expect(decision).toEqual({ kind: 'enter', messages: [context, steered] }) - expect(agent.inject).not.toHaveBeenCalled() - }) - - it('releases the admission listeners when the agent is disposed with the prompt pending', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const source = 'source-session' as SessionId - const context = createUserMessage({ - source: { kind: 'plugin' as const, plugin: 'session-reference' }, - content: [{ type: 'text' as const, text: 'snapshot' }], - }) - ctx.provide('sessionReferenceResolver', { - prepare: () => Promise.resolve({ - content: [{ type: 'text' as const, text: 'continue @Research' }], - additionalContext: context, - }), - } as never) - const api = createApiProxy(ctx, DEFAULTS) - expectOk(await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ - type: 'text' as const, - text: `continue ${formatSessionReferenceMention({ sessionId: source, label: 'Research' })}`, - }], - mode: 'queue' as const, - }))) - const queued = agent.followup.mock.calls[0]?.[0] - if (queued === undefined) throw new Error('expected queued prompt') - - agentEvents(ctx, agent).emit('agent/disposed', { agent }) - - const signal = new AbortController().signal - const batch = agent.inbox.claim('next-turn', 1) - const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: batch, turn: 1, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: batch }), - ) - expect(decision).toEqual({ kind: 'enter', messages: [queued] }) - }) - - it('keeps prepared context paired when a queued prompt moves to steering', async () => { - const ctx = await harness() - const agent = stubAgent(ctx, 'running') - const source = 'source-session' as SessionId - const context = createUserMessage({ - source: { - kind: 'session-reference' as const, - form: 'recall' as const, - version: 1 as const, - references: [{ - sessionId: source, - label: 'Research', - capturedThroughSeq: null, - compacted: false, - originalMessages: 1, - retainedMessages: 1, - omittedMessages: 0, - omittedBytes: 0, - truncated: false, - inputIndex: 0, - }], - }, - content: [{ type: 'text' as const, text: 'snapshot' }], - }) - ctx.provide('sessionReferenceResolver', { - prepare: () => Promise.resolve({ - content: [{ type: 'text' as const, text: 'continue @Research' }], - additionalContext: context, - }), - } as never) - const api = createApiProxy(ctx, DEFAULTS) - expect(expectOk(await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ - type: 'text' as const, - text: formatSessionReferenceMention({ sessionId: source, label: 'Research' }), - }], - mode: 'queue' as const, - })))).toEqual({ accepted: true }) - const queued = agent.inbox.nextTurn[0] - if (queued === undefined) throw new Error('expected queued reference prompt') - - expect(expectOk(await api.sessions.updateQueue(request({ - sessionId: agent.id, - itemId: queued.id, - action: { kind: 'steer' as const }, - })))).toEqual({ accepted: true }) - expect(agent.inbox.nextTurn).toEqual([]) - expect(agent.inbox.nextStep).toEqual([queued]) - - const signal = new AbortController().signal - const batch = agent.inbox.claim('next-step', 1) - const decision = await agentEvents(ctx, agent).waterfall( - 'agent/pre-step', - { messages: batch, turn: 1, step: 1, signal }, - () => Promise.resolve({ kind: 'enter' as const, messages: batch }), - ) - expect(decision).toEqual({ kind: 'enter', messages: [context, queued] }) - }) - - it.each(['queue', 'steer'] as const)( - 'does not deliver a %s prompt when preparation resolves after cancellation', - async (mode) => { - const ctx = await harness() - const agent = stubAgent(ctx) - const controller = new AbortController() - const source = 'source-session' as SessionId - const mention = formatSessionReferenceMention({ sessionId: source, label: 'Research' }) - ctx.provide('sessionReferenceResolver', { - prepare: async () => { - controller.abort() - return { - content: [{ type: 'text' as const, text: '@Research' }], - additionalContext: { - source: { - kind: 'session-reference' as const, - form: 'recall' as const, - version: 1 as const, - references: [{ - sessionId: source, - label: 'Research', - capturedThroughSeq: null, - compacted: false, - originalMessages: 1, - retainedMessages: 1, - omittedMessages: 0, - omittedBytes: 0, - truncated: false, - inputIndex: 0, - }], - }, - content: [{ type: 'text' as const, text: 'snapshot' }], - }, - } - }, - } as never) - const api = createApiProxy(ctx, DEFAULTS) - - const response = await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ type: 'text' as const, text: mention }], - mode, - }), controller.signal) - - expect(expectErr(response).code).toBe('cancelled') - expect(agent.followup).not.toHaveBeenCalled() - expect(agent.steer).not.toHaveBeenCalled() - expect(agent.inject).not.toHaveBeenCalled() - }, - ) - - it('rejects malformed mentions and preparation failures without enqueueing any prompt', async () => { - const ctx = await harness() - const agent = stubAgent(ctx) - const prepare = vi.fn(() => Promise.reject(new Error('snapshot unavailable'))) - ctx.provide('sessionReferenceResolver', { prepare } as never) - const api = createApiProxy(ctx, DEFAULTS) - - const malformed = await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ type: 'text' as const, text: '@[bad](dsh-session:not-canonical)' }], - mode: 'queue' as const, - })) - expect(expectErr(malformed).code).toBe('reference-invalid') - expect(prepare).not.toHaveBeenCalled() - expect(agent.followup).not.toHaveBeenCalled() - - const mention = formatSessionReferenceMention({ - sessionId: 'source-session' as SessionId, - label: 'Research', - }) - const failed = await api.sessions.prompt(request({ - sessionId: agent.id, - content: [{ type: 'text' as const, text: mention }], - mode: 'queue' as const, - })) - expect(expectErr(failed).code).toBe('reference-failed') - expect(agent.followup).not.toHaveBeenCalled() - expect(agent.steer).not.toHaveBeenCalled() - }) -}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 8902715c6b..66ae52f783 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -73,9 +73,6 @@ describe('rpcErrorSchema', () => { details: { provider: 'p', model: 'm' }, }).code).toBe('model-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') - expect(rpcErrorSchema.parse({ code: 'reference-unavailable', message: 'm', details: { kind: 'session' } }).code).toBe('reference-unavailable') - expect(rpcErrorSchema.parse({ code: 'reference-invalid', message: 'm', details: { reason: 'r' } }).code).toBe('reference-invalid') - expect(rpcErrorSchema.parse({ code: 'reference-failed', message: 'm', details: { reason: 'r' } }).code).toBe('reference-failed') expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') @@ -87,8 +84,6 @@ describe('rpcErrorSchema', () => { it('rejects a known code with missing details', () => { expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'reference-unavailable', message: 'm', details: { kind: 'disk' } })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'reference-failed', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: {} })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'command-error', message: 'm' })).toThrow() expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 6abd799c0e..3ed38b3d77 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -62,9 +62,6 @@ { "path": "../../session-query/session-query" }, - { - "path": "../../context/session-reference" - }, { "path": "../../session/session-title" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31610647f4..7fa0b63991 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4862,9 +4862,6 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query - '@deepseek-ai/dsh-session-reference': - specifier: workspace:^ - version: link:../../context/session-reference '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title From 9eab23c17688f1c5bb996b0c91bb2b41635b8dab Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 21:17:30 +0800 Subject: [PATCH 050/137] =?UTF-8?q?fix(release):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=8C=85=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/client/ui-reference/package.json | 2 +- packages/code-runtime/code-runtime-python/package.json | 2 +- packages/context/file-reference-local/package.json | 2 +- packages/context/file-reference/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index f5133b54c5..fefc2a2ad2 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 2b7734dc94..7cea7a25b5 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 48bab806ba..e9bd72702d 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 50ecf97ba1..84a4ef58da 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, From 042f8fd9719c350060c3f2a14868657e1ec87df9 Mon Sep 17 00:00:00 2001 From: fz Date: Mon, 17 Aug 2026 21:23:00 +0800 Subject: [PATCH 051/137] fix(release): align code runtime Python version --- packages/code-runtime/code-runtime-python/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 2b7734dc94..7cea7a25b5 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, From fdf6fec5c023b8fda4b5e76592b004e13aa25c9a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 21:25:12 +0800 Subject: [PATCH 052/137] =?UTF-8?q?test(session-reference):=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E9=A2=84=E5=A4=84=E7=90=86=E5=88=86=E6=94=AF=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/context/session-reference/src/index.ts | 8 +++++--- .../session-reference/tests/session-reference.spec.ts | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 8110f4ead6..433ee1d0b1 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -138,9 +138,11 @@ export class SessionReferenceResolver extends TypertRemoteService { if (references.length === 0) return [message] const resolved = await this.prepare(agent, content, references, signal) const direct = freezeMessage({ ...message, content: resolved.content }) - return resolved.additionalContext === undefined - ? [direct] - : [resolved.additionalContext, direct] + /* v8 ignore if -- a parsed canonical mention always leaves one normalized reference */ + if (resolved.additionalContext === undefined) { + throw new Error('session-reference preparation omitted context for a canonical mention') + } + return [resolved.additionalContext, direct] })) return prepared.flat() } diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 357e8bb0a6..e1eac75658 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -314,7 +314,7 @@ describe('session reference discovery and preparation', () => { content: [{ type: 'text', text: `compare ${formatSessionReferenceMention({ sessionId: source.id, label: 'Research' })} now`, - }], + }, { type: 'reasoning', text: 'preserve this non-text block' }], source: { kind: 'user' }, }) const ordinary = createUserMessage({ @@ -342,7 +342,10 @@ describe('session reference discovery and preparation', () => { }) expect(decision.messages[1]).toMatchObject({ id: direct.id, - content: [{ type: 'text', text: 'compare @Research now' }], + content: [ + { type: 'text', text: 'compare @Research now' }, + { type: 'reasoning', text: 'preserve this non-text block' }, + ], }) expect(decision.messages[1]).not.toBe(direct) expect(decision.messages[2]).toBe(ordinary) From 8d17e63558f90ef7c0e82c3c931e488bd7959ba0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 21:29:14 +0800 Subject: [PATCH 053/137] =?UTF-8?q?chore(ci):=20=E5=88=B7=E6=96=B0=20PR=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a8a028e26fa8bd7812e4224cde7df85f9d7f1faf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 23:31:36 +0800 Subject: [PATCH 054/137] test: avoid Windows metadata-version collision --- .../agent-instructions/tests/agent-instructions.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 2bdee49988..5cd25da780 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -3267,7 +3267,7 @@ describe('dynamic nested workspace context injection', () => { try { await mkdir(join(root, '.git'), { recursive: true }) await write(join(root, 'pkg/AGENTS.md'), 'canonical nested rule') - await write(join(root, 'pkg/CLAUDE.md'), 'divergent nested rule') + await write(join(root, 'pkg/CLAUDE.md'), 'distinct nested instruction') await write(join(root, 'pkg/file.txt'), 'hello') const ctx = new Context() await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) @@ -3279,7 +3279,7 @@ describe('dynamic nested workspace context injection', () => { }) const firstText = blocksText(((await syncedWorkspaceContext(ctx, agent))).content) expect(firstText).toContain('canonical nested rule') - expect(firstText).toContain('divergent nested rule') + expect(firstText).toContain('distinct nested instruction') await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule') await ctx.tools.execute({ From cd4f8b7f46b374dd6d4625034dfc64765d52a8c0 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 01:43:20 +0800 Subject: [PATCH 055/137] fix(subagent): preserve Claude Code failure facts --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 10 +- ...ude-code-and-codex-subagent-backends.zh.md | 10 +- ...agent-noninteractive-permissions.i18n.yaml | 4 +- ...uct-subagent-noninteractive-permissions.md | 8 +- ...-subagent-noninteractive-permissions.zh.md | 8 +- ...8-product-subagent-failure-facts.i18n.yaml | 6 + ...26-08-18-product-subagent-failure-facts.md | 74 ++++++ ...08-18-product-subagent-failure-facts.zh.md | 74 ++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../fixtures/subagent-result-diagnostic.ts | 2 +- .../session.jsonl | 8 +- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 10 +- .../subagent-claude-code/README.zh.md | 10 +- .../subagent-claude-code/src/index.ts | 32 ++- .../subagent/subagent-claude-code/src/run.ts | 224 +++++++++++++++--- .../tests/real-product.spec.ts | 68 +++++- .../tests/subagent-claude-code.spec.ts | 205 +++++++++++++--- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- 24 files changed, 648 insertions(+), 129 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 777fff4e2a..9c2c6dd814 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: f65c0626ad22db8f3e7d2a543c7aa87e58df54d4 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 97ac527b8e89cc07d65aa28102ba43d648b1b64c +2026-08-04-claude-code-and-codex-subagent-backends.md: 829dca8dbd79b408fcfcfd1d88490d793ad4b5ee +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 063bd8c9a59a1b1eccf4893a00efe12a80fbe03f diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index f65c0626ad..829dca8dbd 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. +The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and safe permission decisions, and the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns version-pinned product categories, lifecycle stages, and process outcomes exposed through the same diagnostic. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -52,9 +52,9 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp The public configuration contains an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own. -The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every SDK error subtype, an error-marked success, a missing result, iterator failure, protocol failure, or process failure becomes `error`. When a permission denial or unattended callback contributes to that failure, the result may additionally carry the bounded, non-assistant diagnostic owned by the non-interactive permissions decision. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without permission detail. +The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every other result remains `error`, but its bounded diagnostic preserves the four exact SDK error subtypes, fixed categories for invalid success and missing result, a safe `unknown` fallback, the current `query-start`, `query-run`, `process`, or `teardown` stage, and any observed exit code and signal. A contributing permission decision follows that structured failure line. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without either diagnostic fact. -Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. Query-close failure, process failure, and teardown failure remain independently observable. +Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic. The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract directly: the runtime-only DeepSeek key becomes `ANTHROPIC_AUTH_TOKEN`, the fixed official base gains `/anthropic`, and the main and subagent model variables select the documented DeepSeek models. It starts the production provider and real SDK/CLI, requires one random nonce as the complete answer, persists no credential in settings, and waits for every managed handle to exit. @@ -66,7 +66,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, safe permission diagnostics, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. +The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, a real `error_max_turns` result, a process exit with its outcome, safe permission diagnostics, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. Package tests pin the complete SDK error union, all four stages, unknown fallback, independent code and signal fields, sanitization, success and cancellation omission, and concurrent-run isolation. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. @@ -90,6 +90,6 @@ The project owner's distribution authorization is scoped to the official `@anthr Users delegate through two stable one-shot tools backed by the official product integrations. Explicit Profile installation and host-plane provider placement are owned by the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md); per-Preset tool exposure and foreground-default optional Job scheduling are owned by the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md). This note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of job settlement and process-tree quiescence. -Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. +Every delegation pays for a fresh product process and independent model context. Successful product payload remains final assistant text; a failed product run may separately expose the shared safe diagnostic containing provider-owned permission facts or version-pinned structured failure facts. Background scheduling additionally exposes generic Job ids, status, completion notices, and collection or cancellation results. Product-native configuration makes behavior depend on the deployment's installed product, account state, workspace settings, and selected Provider mode. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. Compatibility is pinned by package-level unit coverage, keyless real-product loopback tests, credentialed DeepSeek nonce tests, public Loader composition, built-package and NodeNext consumer checks, generated documentation and notices, and the repository CI matrix. A supported product or DeepSeek endpoint/model baseline change must refresh those facts; production performs no separate runtime version probe. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 97ac527b8e..063bd8c9a5 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 模式选择与安全权限决定,[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)则负责通过同一诊断公开锁定产品版本的类别、生命周期阶段与进程结果。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -52,9 +52,9 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 公开配置包含显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。 -只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。所有 SDK 错误子类型、标记为错误的成功消息、结果缺失、迭代器失败、协议失败或进程失败都会成为 `error`。当权限拒绝或无人值守回调参与了该失败时,结果还可以携带由非交互权限决策负责的有界、非 assistant 诊断。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带权限说明。 +只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。其他所有结果仍成为 `error`,但其有界诊断会保留四种准确 SDK 错误子类型、标记为错误的成功消息与结果缺失所对应的固定类别、安全的 `unknown` 回退、当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,以及已观测到的退出码和信号。若权限决定也参与失败,它会跟在结构化失败行之后。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。 -启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。Query 关闭失败、进程失败和清理失败仍可彼此独立地观察。 +启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。 带密钥 Claude Code e2e 直接使用官方 DeepSeek Claude Code 约定:仅在运行时提供的 DeepSeek 密钥会映射为 `ANTHROPIC_AUTH_TOKEN`,固定的官方基础 URL 会追加 `/anthropic`,主模型与 subagent 模型变量会选择文档所示的 DeepSeek 模型。该测试会启动生产提供方与真实 SDK 和 CLI,要求一个随机数作为完整答案,不会把任何凭据持久化到设置中,并等待所有受管句柄退出。 @@ -66,7 +66,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、安全权限诊断、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 +Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、真实的 `error_max_turns` 结果、携带进程结果的提前退出、安全权限诊断、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。包测试固定完整 SDK 错误联合、四个阶段、unknown 回退、相互独立的退出码与信号字段、脱敏、成功与取消时省略诊断,以及并发运行隔离。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -90,6 +90,6 @@ Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Cl 用户通过官方产品集成支持的两个稳定一次性工具进行委派。显式 Profile 安装与 host plane 提供方放置由[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责;按 Preset 暴露工具以及默认前台且可选通用 Job 的调度方式由[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责。本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占作业结算与进程树完全停稳的责任。 -每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 +每次委派都要承担新建产品进程和独立模型上下文的开销。成功的产品载荷仍只有最终 assistant 文本;失败的产品运行可以另行公开共享安全诊断,其中包含由提供方拥有的权限事实,或锁定版本产品提供的结构化失败事实。后台调度还会额外公开通用 Job id、状态、完成通知以及收集或取消结果。产品原生配置使行为取决于部署环境中安装的产品、账户状态、工作区设置和所选提供方模式。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 兼容性由包级单元测试覆盖率、无密钥真实产品回环测试、带密钥 DeepSeek 随机数测试、公开 Loader 组合、已构建包与 NodeNext 消费方检查、生成的文档与声明以及仓库 CI 矩阵共同锁定。更改受支持的产品基线或 DeepSeek 端点/模型基线时必须刷新这些事实;生产环境不会另行执行运行时版本探测。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 940ca44aae..6b3e5d13ff 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: df1f0d9939e951f16070729615a3779f1f7c2ddc -2026-08-15-product-subagent-noninteractive-permissions.zh.md: 982b4409e08a506dec828db15c8c4aa5fcc36883 +2026-08-15-product-subagent-noninteractive-permissions.md: 9327412cfdd306f7867f989c8cfc091941cb26e6 +2026-08-15-product-subagent-noninteractive-permissions.zh.md: a7992b58a14aff94f93397bfa0aa21b9fe727fb2 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index df1f0d9939..9327412cfd 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -44,9 +44,9 @@ The Provider overrides only those thread fields. `CODEX_HOME`, project configura ### Failure diagnostic -`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. +`SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns non-permission product categories, lifecycle stages, and process outcomes carried by the same field. -Each product records only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. When a permission fact contributes to a published run that settles as `error`, the Provider attaches the diagnostic without adding it to assistant output, structured output, or `subagent/end.lastAssistantMessage`. +Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. Claude Code places its structured failure line before the latest contributing permission fact; Codex retains its permission-only diagnostic in this product version. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`. The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior. @@ -63,7 +63,7 @@ The foreground consumer presents the stop-reason headline, then the optional dia ## Verification -Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared diagnostic presentation while the model-facing product tool schemas contain no permission parameter. +Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared foreground and Job diagnostic presentation while the model-facing product tool schemas contain no permission parameter. ## Alternatives considered @@ -83,6 +83,6 @@ Package tests pin every allowed and rejected Config value, the exact SDK and app Profiles can select each product's native restricted, automatic, planning/edit-accepting where supported, or bypass behavior before the Provider starts, while both safe defaults never ask a person. Broader modes remain explicit deployment choices and retain their native sandbox consequences. -Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. That diagnostic can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound it before result settlement. +Permission failures become visible to both foreground parents and one-shot background Jobs without turning infrastructure text into an assistant answer. The same field can also carry the separately owned structured failure facts. It can enter model context, Job notices, API projections, and Job UI through the ordinary consumer paths, so the Provider must sanitize and bound the complete text before result settlement. The change adds no product session persistence, human approval channel, dynamic permission operation, progress stream, retry policy, or rollback. Other Providers remain valid without producing a diagnostic or exposing a permission-mode Config. diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index 982b4409e0..a7992b58a1 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -44,9 +44,9 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 ### 失败诊断 -`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。 +`SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责由同一字段承载的非权限产品类别、生命周期阶段与进程结果。 -每个产品都只记录有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。当一项权限事实参与了已经发布、最终以 `error` 结算的运行时,提供方会附加诊断,但不会把它写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 +每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。Claude Code 会把结构化失败行放在最新参与失败的权限事实之前;当前产品版本中的 Codex 仍只生成权限诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。 @@ -63,7 +63,7 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 ## Verification -包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录共享诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。 +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录前台与 Job 共享的诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。 ## Alternatives considered @@ -83,6 +83,6 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 Profile 可以在提供方启动前选择各产品原生的受限、自动、在产品支持时仅规划/编辑放行,或 bypass 行为,而两个安全默认值都绝不会询问人员。更宽松的模式仍是显式部署选择,并保留其原生沙箱后果。 -权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。该诊断可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前完成脱敏和限长。 +权限失败会同时到达前台父 agent 和一次性后台 Job,且不会把基础设施文本伪装成 assistant 回答。同一字段还可以承载由另一项决策负责的结构化失败事实。它可以沿普通消费路径进入模型上下文、Job 通知、API 投影与 Job UI,因此提供方必须在结果结算前对完整文本完成脱敏和限长。 本改动不增加产品会话持久化、人工审批通道、动态权限操作、进度流、重试策略或回滚。其他提供方无需产生诊断或公开权限模式 Config,仍然保持合法。 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml new file mode 100644 index 0000000000..b27044310b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.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-18-product-subagent-failure-facts.md +2026-08-18-product-subagent-failure-facts.md: 4380e36d172f395692d2d84c97d45cba95701f9f +2026-08-18-product-subagent-failure-facts.zh.md: d601becdf14bd74ae871a66d4798ffe9c49b6490 diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md new file mode 100644 index 0000000000..4380e36d17 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md @@ -0,0 +1,74 @@ +# Agent Note: Product subagents expose bounded structured failure facts + +Status: implemented + +English | [中文](2026-08-18-product-subagent-failure-facts.zh.md) + +## Problem + +The [Claude Code and Codex product providers](2026-08-04-claude-code-and-codex-subagent-backends.md) receive structured product failures, but a published run historically flattened most of them to the shared `error` stop reason. Product logs retained detail that the foreground parent and a [one-shot background Job](2026-08-12-product-subagent-one-shot-background-tasks.md) could not use to distinguish a product limit, an execution failure, or an early process exit. + +Copying SDK error text, app-server payloads, or stderr into the result would expose task text, paths, environment values, credentials, or product internals. Adding shared error fields would also make the provider-neutral [subagent seam](2026-06-21-subagent-capability-seam.md) own product version vocabularies that change independently. + +## Decision + +Each product Provider owns the mapping from its pinned official error union, current operation, and managed process outcome to one fixed safe diagnostic line. `SubagentResult` remains unchanged: consumers receive the existing bounded `diagnostic` string and do not parse its product-private fields. + +### Safe diagnostic + +The structured line has this fixed order: + +```text +Product subagent failure (product: ; stage: ; category: ; exit code: ; signal: ) +``` + +The Provider omits unavailable exit fields. Exit code and signal are independent facts and are each retained when observed. A contributing permission decision from the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) follows the structured line; the latest safe permission fact remains operation-local. The shared result boundary limits the complete text to 4096 UTF-8 bytes. + +Successful results and local cancellation expose no failure fact. Raw product errors, stderr, tool input, paths, environment values, credentials, and protocol payloads never enter the diagnostic. Startup and cleanup rejections use the same safe line in their Error message while retaining the original failure only on the internal cause chain and in Host logging. + +### Claude Code facts + +Agent SDK 0.3.220 defines four error subtypes: `error_during_execution`, `error_max_turns`, `error_max_budget_usd`, and `error_max_structured_output_retries`. The Claude Code Provider preserves each exact subtype as the category while keeping the shared stop reason `error`. An error-marked or blank success uses `invalid-success`, a missing result uses `missing-result`, a process exit before an SDK terminal result uses `process-exit`, and an unrecognized value or exception uses `unknown` without copying the value. + +| Stage | Owned operation | Observable failure | +| --- | --- | --- | +| `query-start` | Native executable resolution, SDK query construction, and unpublished rollback | `start()` rejects with fixed safe facts and any process outcome observed before rollback | +| `query-run` | Published SDK message iteration and strict terminal-result validation | The run resolves as `error` with the exact known subtype or a fixed result category | +| `process` | Managed CLI exits before the SDK supplies a terminal result | The run resolves as `error` with `process-exit` and the available exit code and signal | +| `teardown` | Query close and managed process-tree release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait | + +The Codex Provider retains its existing result mapping: `contextWindowExceeded` is `max-tokens`, other turn failures remain `error`, and permission-related paths may carry their existing safe diagnostic. Other Codex error-info members are not represented as shared categories by this decision's current implementation. + +### Ownership and lifecycle + +| Fact or resource | Owner | Consumer behavior | +| --- | --- | --- | +| Product error category | Pinned official SDK or app-server version | The Provider maps only the declared structured union and uses `unknown` outside it | +| Current failure stage | Product Provider operation | Derived at the failure site; never persisted or used as a recovery state | +| Exit code and signal | `dsh-subprocess` process handle | The Provider displays observed values without inferring missing ones | +| Diagnostic bytes and delivery | `dsh-subagent`, foreground tool, and Job runtime | The same bounded text is presented separately from assistant output in both scheduling modes | +| Raw product failure | Product runtime and Host log | It remains internal and never becomes model-visible result text | + +## Verification + +Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. The real SDK/CLI fixture produces an actual `error_max_turns` result and an actual early process exit while proving whole-tree quiescence. The keyless ACP snapshot records the same failure diagnostic in foreground error output, the background completion notice, and `job_output`. + +## Alternatives considered + +**Return raw SDK errors, app-server payloads, or stderr.** These values can contain commands, paths, workspace content, environment values, credentials, or upstream prose. A fixed allowlisted mapping preserves actionable facts without expanding the model-visible trust boundary. + +**Add a shared product-error enum or structured result fields.** Claude Code and Codex version their error unions independently. A shared enum would duplicate those authorities and force unrelated Providers and consumers to track product releases. + +**Parse generic stderr and exception messages.** Free-form text is neither stable nor safe. Only pinned structured product fields and the managed process outcome qualify as diagnostic input. + +**Persist stages or add a recovery controller.** The stage is derived from the current call site only when a failure is reported. Persistence, retries, resume, and remediation need separate ownership and user contracts. + +**Map product limits to new shared stop reasons.** Claude Code turn and budget limits are not token-window exhaustion, and an error category does not establish refusal semantics. Existing stop reasons remain unchanged. + +## Consequences + +The parent can distinguish important Claude Code product limits, invalid terminal results, unknown query failures, and early process exits without receiving raw product text. Foreground and background scheduling preserve the same fact because both consume one `SubagentResult`. + +The diagnostic is display text rather than a new public protocol. Callers may present it but must not branch on its punctuation or product-private category names. A pinned product-version upgrade must update the Provider mapping and evidence when its official error union changes. + +This decision adds no product session persistence, retry policy, recovery state, stderr classifier, authentication or configuration taxonomy, progress stream, or human interaction path. diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md new file mode 100644 index 0000000000..d601becdf1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md @@ -0,0 +1,74 @@ +# Agent Note: 产品 subagent 公开有界结构化失败事实 + +Status: implemented + +[English](2026-08-18-product-subagent-failure-facts.md) | 中文 + +## Problem + +[Claude Code 与 Codex 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.md)会收到结构化产品失败,但已发布运行以往会把其中大多数压成共享的 `error` 终止原因。产品日志保留了细节,前台父 agent 与[一次性后台 Job](2026-08-12-product-subagent-one-shot-background-tasks.md)却无法据此区分产品限制、执行失败或进程提前退出。 + +若把 SDK 错误文本、app-server payload 或 stderr 复制进结果,就会暴露任务文本、路径、环境值、凭证或产品内部信息。若增加共享错误字段,又会让提供方无关的 [subagent seam](2026-06-21-subagent-capability-seam.md)拥有彼此独立变化的产品版本词汇。 + +## Decision + +每个产品提供方分别拥有从锁定版本官方错误联合、当前操作和受管进程结果到一行固定安全诊断的映射。`SubagentResult` 保持不变:消费方仍接收现有的有界 `diagnostic` 字符串,而且不解析其中由产品私有的字段。 + +### 安全诊断 + +结构化行采用以下固定顺序: + +```text +Product subagent failure (product: ; stage: ; category: ; exit code: ; signal: ) +``` + +提供方会省略不可用的退出字段。退出码与信号是相互独立的事实,只要已观测到就分别保留。来自[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)且参与失败的权限决定会跟在结构化行之后;最新的安全权限事实仍只属于当前操作。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。 + +成功结果与本地取消都不公开失败事实。原始产品错误、stderr、工具输入、路径、环境值、凭证和协议 payload 绝不会进入诊断。启动与清理拒绝会在 Error 消息中使用同一安全行,而原始失败只保留在内部 cause 链与 Host 日志中。 + +### Claude Code 事实 + +Agent SDK 0.3.220 定义四种错误子类型:`error_during_execution`、`error_max_turns`、`error_max_budget_usd` 和 `error_max_structured_output_retries`。Claude Code 提供方会把每种准确子类型保留为类别,同时维持共享终止原因 `error`。标记为错误或内容空白的成功消息使用 `invalid-success`,缺失结果使用 `missing-result`,SDK 给出终态结果前发生的进程退出使用 `process-exit`,无法识别的值或异常使用 `unknown`,且不会复制原值。 + +| 阶段 | 归属操作 | 可观察失败 | +| --- | --- | --- | +| `query-start` | 原生可执行文件解析、SDK query 构造与未发布回滚 | `start()` 以固定安全事实和回滚前已观测到的进程结果拒绝 | +| `query-run` | 已发布 SDK 消息迭代与严格终态结果校验 | 运行以 `error` 兑现,并携带准确已知子类型或固定结果类别 | +| `process` | SDK 提供终态结果之前受管 CLI 已退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码和信号 | +| `teardown` | Query 关闭与受管进程树释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 | + +Codex 提供方保留既有结果映射:`contextWindowExceeded` 是 `max-tokens`,其他轮次失败仍是 `error`,权限相关路径可以携带既有安全诊断。本决策的当前实现不会把其他 Codex error-info 成员表示为共享类别。 + +### 所有权与生命周期 + +| 事实或资源 | Owner | 消费方行为 | +| --- | --- | --- | +| 产品错误类别 | 锁定版本的官方 SDK 或 app-server | 提供方只映射已声明的结构化联合,并对联合外值使用 `unknown` | +| 当前失败阶段 | 产品提供方操作 | 只在失败点派生;绝不持久化,也不作为恢复状态 | +| 退出码与信号 | `dsh-subprocess` 进程句柄 | 提供方展示已观测值,不推测缺失值 | +| 诊断字节与送达 | `dsh-subagent`、前台工具与 Job 运行时 | 两种调度模式都把同一份有界文本与 assistant 输出分开呈现 | +| 原始产品失败 | 产品运行时与 Host 日志 | 只保留在内部,绝不成为模型可见的结果文本 | + +## Verification + +Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。真实 SDK/CLI fixture 会产生真实的 `error_max_turns` 结果与真实的进程提前退出,并证明整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录同一份失败诊断。 + +## Alternatives considered + +**返回原始 SDK 错误、app-server payload 或 stderr。** 这些值可能包含命令、路径、工作区内容、环境值、凭证或上游文本。固定白名单映射可以保留可操作事实,同时不扩大模型可见的信任边界。 + +**增加共享产品错误 enum 或结构化结果字段。** Claude Code 与 Codex 各自独立版本化错误联合。共享 enum 会复制这些权威,并迫使无关提供方和消费方跟随产品版本。 + +**解析通用 stderr 与异常消息。** 自由文本既不稳定也不安全。只有锁定版本产品提供的结构化字段和受管进程结果可以成为诊断输入。 + +**持久化阶段或增加恢复控制器。** 阶段只在报告失败时从当前调用点派生。持久化、重试、resume 与修复需要独立的所有权和用户约定。 + +**把产品限制映射为新的共享终止原因。** Claude Code 的轮次和预算限制并不表示 token 窗口耗尽,错误类别也不能证明拒绝语义。既有终止原因保持不变。 + +## Consequences + +父 agent 可以区分重要的 Claude Code 产品限制、无效终态结果、未知 query 失败和进程提前退出,而不会收到原始产品文本。前台与后台调度会保留同一事实,因为二者都消费同一个 `SubagentResult`。 + +诊断只是展示文本,不是新的公开协议。调用方可以呈现它,但不得根据其标点或产品私有类别名称进行分支。锁定产品版本升级并改变官方错误联合时,必须同步更新提供方映射与证据。 + +本决策不增加产品会话持久化、重试策略、恢复状态、stderr 分类器、身份验证或配置分类体系、进度流或人工交互路径。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9136d2952b..497fe74b47 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: ebef0167d5ecbe0c71d201cd2cc07962ba89c48d -config-catalog.zh.md: 4b2ffba0e931c4c515097950e3e69b5744cb5f37 +config-catalog.md: c4c70a9bc8a0ae964189b7b1a22443ec18b4b8a2 +config-catalog.zh.md: b034155795efad1c808d3147c8223b29160c6283 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ebef0167d5..c4c70a9bc8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2103,7 +2103,7 @@ export interface Config { export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] ``` -Source: [`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) +Source: [`packages/subagent/subagent-claude-code/src/index.ts:36`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4b2ffba0e9..b034155795 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2105,7 +2105,7 @@ export interface Config { export type ClaudeCodePermissionMode = typeof CLAUDE_CODE_PERMISSION_MODES[number] ``` -来源:[`packages/subagent/subagent-claude-code/src/index.ts:35`](../packages/subagent/subagent-claude-code/src/index.ts) +来源:[`packages/subagent/subagent-claude-code/src/index.ts:36`](../packages/subagent/subagent-claude-code/src/index.ts) diff --git a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts index 3f9f9ebe55..56626802a4 100644 --- a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts +++ b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts @@ -11,7 +11,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' export const name = 'subagent-result-diagnostic' export const inject = ['subagents'] -const DIAGNOSTIC = 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt' +const DIAGNOSTIC = 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)' class DiagnosticProvider implements SubagentProvider { readonly name = 'snapshot-diagnostic' diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl index f1c5ffe374..b9a725162e 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"92e33995-2f02-4ad5-aec1-9df82cf4d583"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}} -{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"4e84e7b3-40c1-488e-b119-45e8bd7ce448"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"f63d21ab-ccdc-44f2-9a96-4c60b46e5318"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1786781990613,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1786781990618,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -25,12 +25,12 @@ {"type":"assistant/chunk","seq":23,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"2fb444e2-7a52-4963-988e-b1ecbc3744d5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} {"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}} -{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"}]}} +{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Claude Co…"},"role":"user","id":"05f93dde-37e9-40e7-92d5-f8de526a8bee"}]}} {"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_diagnostic_background"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"3377f724-b4a7-4ce1-bed7-774f174917d6"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1786781990627,"data":{"turn":1,"step":2}} {"type":"agent/inbox/spliced","seq":29,"time":1786781990627,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":30,"time":1786781990632,"data":{"turn":1,"step":3}} -{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Claude Code unattended decision (mode: dontA…"},"role":"user","id":"de606545-e637-4d9a-ba17-4c722a7331fd"},"surfaceOp":"append"} +{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Claude Co…"},"role":"user","id":"05f93dde-37e9-40e7-92d5-f8de526a8bee"},"surfaceOp":"append"} {"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}} {"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}} @@ -38,7 +38,7 @@ {"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f43f988b-bc08-4811-8671-8edc0613f0d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} {"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} -{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt]"}],"isError":false}],"role":"user","id":"6785120f-ae46-48d0-9f3f-d6cd1e6fc5d7"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]"}],"isError":false}],"role":"user","id":"10ac5635-c519-4f69-ab5a-cb0f930e9df0"}},"sourceEventSeqs":[38],"surfaceOp":"append"} {"type":"step/end","seq":40,"time":1786781990640,"data":{"turn":1,"step":3}} {"type":"step/start","seq":41,"time":1786781990645,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 0185afd2de..c2fe8df4db 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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-claude-code/README.md -README.md: be3b2262addc487e545fed1f792600a9a5ca24c0 -README.zh.md: 7ea1b8ca7243790afd387b04d776088cea012718 +README.md: 21beb0a0534e601f9dd26d36f53d1fb5f09b4e07 +README.zh.md: c83999ee7b2d38e9c4ee57ba74df25217998bffd diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index be3b2262ad..21beb0a053 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -8,15 +8,15 @@ This package registers the fixed `claude-code` subagent provider. Each accepted `start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It creates one private `AbortController`, calls the official SDK `query()`, and publishes the run only after the SDK's `spawnClaudeCodeProcess` hook has supplied a live CLI handle owned by [`dsh-subprocess`](../../subprocess/subprocess/README.md). A failure or cancellation before publication closes the query, terminates any acquired process tree, waits for it to exit, and rejects `start()`. -The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every SDK error subtype, an error-marked success, a missing answer, iterator failure, protocol failure, or process failure maps to `error`; the provider produces neither `max-tokens` nor `refusal`. +The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every failure still maps to `error`: the four error subtypes in Agent SDK 0.3.220 retain their exact category, an error-marked or blank success becomes `invalid-success`, a missing result becomes `missing-result`, an unclassified query failure becomes `unknown`, and an early CLI exit becomes `process-exit`. The diagnostic also names the current `query-start`, `query-run`, `process`, or `teardown` stage and independently includes an observed exit code and signal. The provider produces neither `max-tokens` nor `refusal`. -Local cancellation wins the result race and maps to `aborted`. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains only on the internal cause chain. Result failure and independent teardown failure remain separate. ## Native settings and interaction The provider deliberately omits the SDK `settingSources` option. The official SDK therefore reads the host's normal user, project, and local Claude settings relative to the parent Session cwd, including native account state and product configuration. The provider neither copies nor filters those files and does not create or modify login state. The Profile-selected `permissionMode` is the one query-level override: Claude Code still owns its settings and sandbox, while the selected native mode decides how this unattended query handles permission checks. -Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. A permission denial or unattended callback that contributes to a failed run produces an optional `SubagentResult.diagnostic` containing only the product, effective mode, request category, decision, and fixed safe reason; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs do not expose the captured failure detail. +Each query sets `persistSession: false` and disables `AskUserQuestion`. Except in bypass mode, `canUseTool` immediately denies requests that still require human approval. Plan mode also places `ExitPlanMode` in the SDK's `disallowedTools`, so native settings cannot pre-approve a transition back to execution and the model must return the completed plan as its final answer. MCP elicitation is declined, the known refusal fallback dialog is cancelled, and undeclared dialog kinds use the SDK's no-dialog failure behavior. These decisions never wait for a user interface. When both facts contribute to a failed run, `SubagentResult.diagnostic` contains the structured failure line first and the latest safe permission decision second; the shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful and locally cancelled runs expose neither captured fact. ## Capabilities and context @@ -93,7 +93,7 @@ Independent of the parent request cache. Reuse depends only on Claude Code's own #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session. +Through `dsh-tool-subagent`, a foreground call gives the parent the strict final Claude Code answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. That diagnostic can distinguish the fixed SDK error category, lifecycle stage, and observed process outcome without copying raw product text. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the same final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Claude Code reasoning, tool activity, intermediate messages, stderr, workspace diffs, usage, product ids, tool inputs, and raw protocol payloads are not copied into the parent Session. #### Token effect @@ -107,7 +107,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. -- **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced with its lifecycle stage and the safe `unknown` fallback rather than a separate public classification; the plugin provides no installer or login flow. - **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up. - **No human interaction path** — `AskUserQuestion` is disabled, permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of suspending. - **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local, while generic Job ids, notices, and status come from the shared job runtime. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 7ea1b8ca72..c83999ee7b 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -8,15 +8,15 @@ `start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。它会创建一个私有 `AbortController`,调用官方 SDK 的 `query()`,并仅在 SDK 的 `spawnClaudeCodeProcess` 钩子已经提供由 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 管理的活动 CLI 句柄后发布此次运行。若在发布前发生失败或取消,它会关闭 query、终止所有已取得的进程树并等待其退出,然后拒绝 `start()` 调用。 -SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有 SDK 错误子类型、标记为错误的成功消息、缺失答案、迭代器失败、协议失败或进程失败都映射为 `error`;该提供方不会产生 `max-tokens` 或 `refusal`。 +SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有失败仍映射为 `error`:Agent SDK 0.3.220 的四种错误子类型保留准确类别;标记为错误或内容空白的成功消息成为 `invalid-success`;缺失结果成为 `missing-result`;未分类的 query 失败成为 `unknown`;CLI 提前退出成为 `process-exit`。诊断还会注明当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,并分别保留已观测到的退出码与信号。该提供方不会产生 `max-tokens` 或 `refusal`。 -本地取消会在结果竞态中胜出并映射为 `aborted`。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链上。结果失败与独立的清理失败仍彼此分离。 ## 原生设置与交互 提供方故意省略 SDK 的 `settingSources` 选项。因此,官方 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置,包括原生账户状态与产品配置。提供方既不复制也不过滤这些文件,也不会创建或修改登录状态。Profile 选择的 `permissionMode` 是唯一的 query 级覆盖:Claude Code 仍拥有其设置与沙箱,而所选原生模式决定这个无人值守 query 如何处理权限检查。 -每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。若权限拒绝或无人值守回调参与了一次失败运行,提供方会生成可选的 `SubagentResult.diagnostic`,其中只包含产品、有效模式、请求类别、决定与固定的安全原因;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消不会公开已捕获的失败说明。 +每次 query 都设置 `persistSession: false` 并禁用 `AskUserQuestion`。除 bypass 模式外,`canUseTool` 会立即拒绝仍需人工审批的请求。Plan 模式还会把 `ExitPlanMode` 放入 SDK 的 `disallowedTools`,因此原生 settings 无法预先放行回到执行模式的转换,模型必须把完整计划作为最终答案返回。MCP elicitation 会被拒绝,已知的拒绝回退对话会被取消,未声明的对话类型则使用 SDK 的无对话失败行为。这些决定都不会等待用户界面。当两类事实共同参与一次失败运行时,`SubagentResult.diagnostic` 会先写入结构化失败行,再写入最新的安全权限决定;共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。成功运行与本地取消都不会公开已捕获的事实。 ## 能力与上下文 @@ -93,7 +93,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 #### 模型看到的内容 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。 +通过 `dsh-tool-subagent`,前台调用会让父级模型看到符合严格成功条件的 Claude Code 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。该诊断可以区分固定 SDK 错误类别、生命周期阶段和已观测的进程结果,而不复制原始产品文本。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开同一最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Claude Code 的推理、工具活动、中间消息、stderr、工作区差异、用量信息、产品标识符、工具输入和原始协议载荷均不会复制到父会话。 #### 对 token 的影响 @@ -107,7 +107,7 @@ Claude Code 子级会在一个全新的 SDK query 中接收独立文本任务。 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 -- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败会公开其生命周期阶段与安全的 `unknown` 回退,而不会增加单独的公开分类;本插件不提供安装程序或登录流程。 - **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败而不会挂起。 - **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部,通用 Job id、通知与状态来自共享作业运行时。 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 4095ca8f8e..1359b1d642 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -21,6 +21,7 @@ import { CLAUDE_CODE_PERMISSION_MODES, DEFAULT_CLAUDE_CODE_PERMISSION_MODE, DEFAULT_DISPOSE_GRACE_MS, + claudeCodeStartupFailure, startClaudeCodeRun, type ClaudeCodePermissionMode, type ClaudeCodeRunSpec, @@ -78,17 +79,29 @@ class ClaudeCodeProvider implements SubagentProvider { 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', ) } - const executable = await this.ctx.subprocess.resolveExecutable( - 'claude', - this.config.env, - request.signal, - ) - const spec: ClaudeCodeRunSpec = { - cwd: resolveChildCwd( + let cwd: string + let executable: string + try { + cwd = resolveChildCwd( 'subagent-claude-code', undefined, parentCwd, - ), + ) + executable = await this.ctx.subprocess.resolveExecutable( + 'claude', + this.config.env, + request.signal, + ) + } catch (error: unknown) { + if (request.signal.aborted) { + throw new Error( + 'subagent-claude-code: request was aborted before SDK startup', + ) + } + throw claudeCodeStartupFailure(error) + } + const spec: ClaudeCodeRunSpec = { + cwd, executable, permissionMode: this.config.permissionMode, env: this.config.env, @@ -96,7 +109,8 @@ class ClaudeCodeProvider implements SubagentProvider { spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), onError: (error, stopReason) => { this.ctx.logger.warn( - `subagent-claude-code: child run failed (${stopReason}): ${error.message}`, + `subagent-claude-code: child run failed (${stopReason}): %o`, + error, ) }, } diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 82dcfb4eb4..3e5b3c0905 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -28,6 +28,7 @@ import { import { scrubbedParentEnv, type SubprocessHandle, + type SubprocessOutcome, type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import { @@ -57,6 +58,83 @@ const SUPPORTED_UNATTENDED_DIALOG_KINDS = [ 'refusal_fallback_prompt', ] satisfies NonNullable +type ClaudeCodeErrorSubtype = Exclude + +type ClaudeCodeFailureStage = + | 'query-start' + | 'query-run' + | 'process' + | 'teardown' + +type ClaudeCodeFailureCategory = + | ClaudeCodeErrorSubtype + | 'invalid-success' + | 'missing-result' + | 'process-exit' + | 'unknown' + +interface ClaudeCodeFailureFacts { + readonly stage: ClaudeCodeFailureStage + readonly category: ClaudeCodeFailureCategory + readonly outcome?: SubprocessOutcome | undefined +} + +function failureDiagnostic(facts: ClaudeCodeFailureFacts): string { + const fields = [ + 'product: Claude Code', + `stage: ${facts.stage}`, + `category: ${facts.category}`, + ] + const exitCode = facts.outcome?.exitCode + if (exitCode !== null && exitCode !== undefined) { + fields.push(`exit code: ${exitCode}`) + } + const signal = facts.outcome?.signal + if (signal !== null && signal !== undefined) { + fields.push(`signal: ${signal}`) + } + return `Product subagent failure (${fields.join('; ')})` +} + +class ClaudeCodeFailure extends Error { + constructor( + readonly facts: ClaudeCodeFailureFacts, + cause?: unknown, + ) { + super( + `subagent-claude-code: ${failureDiagnostic(facts)}`, + cause === undefined ? undefined : { cause }, + ) + this.name = 'ClaudeCodeFailure' + } +} + +function sdkFailureCategory( + subtype: string, +): ClaudeCodeErrorSubtype | 'unknown' { + switch (subtype) { + case 'error_during_execution': + case 'error_max_turns': + case 'error_max_budget_usd': + case 'error_max_structured_output_retries': + return subtype + default: + return 'unknown' + } +} + +/** + * Hide an unpublished product startup failure behind fixed safe facts. + * @param cause - original host-side failure retained only on the Error cause chain. + * @returns a rejection safe to expose through the subagent start boundary. + */ +export function claudeCodeStartupFailure(cause: unknown): Error { + return new ClaudeCodeFailure({ + stage: 'query-start', + category: 'unknown', + }, cause) +} + function unattendedDiagnostic( mode: ClaudeCodePermissionMode, request: 'tool permission' | 'MCP elicitation' | 'user dialog', @@ -90,6 +168,10 @@ function thrown(value: unknown): Error { /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) } + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} /* jscpd:ignore-end */ /** @@ -120,15 +202,23 @@ export function textTask(prompt: readonly ContentBlock[]): string { * @returns exact final text for a successful, non-error result. */ export function successfulResult(message: SDKResultMessage): string { - if ( - message.subtype !== 'success' - || message.is_error - || message.result.trim().length === 0 - ) { - const detail = message.subtype === 'success' - ? 'success result was marked as an error or contained no answer' - : message.errors.join('; ') || message.subtype - throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`) + if (message.subtype !== 'success') { + const category = sdkFailureCategory(message.subtype) + const detail = category === 'unknown' + ? undefined + : message.errors.join('; ') + throw new ClaudeCodeFailure( + { stage: 'query-run', category }, + detail === undefined || detail.length === 0 + ? undefined + : new Error(detail), + ) + } + if (message.is_error || message.result.trim().length === 0) { + throw new ClaudeCodeFailure({ + stage: 'query-run', + category: 'invalid-success', + }) } return message.result } @@ -154,7 +244,10 @@ export async function consumeClaudeQuery( answer = successfulResult(message) } if (answer === undefined) { - throw new Error('subagent-claude-code: Claude Code ended without a result') + throw new ClaudeCodeFailure({ + stage: 'query-run', + category: 'missing-result', + }) } return { output: [{ type: 'text', text: answer }], @@ -173,6 +266,7 @@ export async function disposeClaudeCodeChild( child: SubprocessHandle, ): Promise { const failures: Error[] = [] + let outcome: SubprocessOutcome | undefined try { query?.close() } catch (error: unknown) { @@ -188,17 +282,24 @@ export async function disposeClaudeCodeChild( } } try { - await child.done + outcome = await child.done } catch (error: unknown) { failures.push(thrown(error)) } const firstFailure = failures[0] - if (failures.length === 1 && firstFailure !== undefined) throw firstFailure - if (failures.length > 1) { + if (firstFailure !== undefined) { + const facts = { + stage: 'teardown', + category: 'unknown', + outcome, + } as const + if (failures.length === 1) { + throw new ClaudeCodeFailure(facts, firstFailure) + } throw new AggregateError( - failures, - 'subagent-claude-code: query and process cleanup failed', + failures.map(failure => new ClaudeCodeFailure(facts, failure)), + `subagent-claude-code: ${failureDiagnostic(facts)}`, ) } } @@ -296,9 +397,21 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let query: Query | undefined - let diagnostic: string | undefined - const captureDiagnostic = (value: string): void => { - diagnostic = value + let processOutcome: SubprocessOutcome | undefined + let failureDetail: string | undefined + let permissionDetail: string | undefined + const capturePermissionDiagnostic = (value: string): void => { + permissionDetail = value + } + const collectDiagnostic = (): string => [failureDetail, permissionDetail] + .filter((value): value is string => value !== undefined) + .join('\n') + const captureChild = (captured: SubprocessHandle): void => { + child = captured + void captured.done.then( + (outcome: SubprocessOutcome) => { processOutcome = outcome }, + () => undefined, + ) } try { query = officialQuery({ @@ -306,10 +419,8 @@ export async function startClaudeCodeRun( options: claudeQueryOptions( spec, controller, - (captured) => { - child = captured - }, - captureDiagnostic, + captureChild, + capturePermissionDiagnostic, ), }) if (child === undefined || child.pid <= 0) { @@ -323,46 +434,83 @@ export async function startClaudeCodeRun( } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = controller.signal.aborted + await Promise.resolve() + const startupOutcome = processOutcome + const startupFacts = { + stage: 'query-start', + category: 'unknown', + outcome: startupOutcome, + } as const + const startupFailure = (): ClaudeCodeFailure => new ClaudeCodeFailure( + startupFacts, + thrown(error), + ) requestCancel() if (child !== undefined) { try { await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { + const failure = startupFailure() throw new AggregateError( - [thrown(error), thrown(disposeError)], - 'subagent-claude-code: startup failed and CLI cleanup also failed', + [failure, thrown(disposeError)], + `${failure.message}; startup cleanup also failed`, ) } } else if (query !== undefined) { try { query.close() } catch (disposeError: unknown) { + const failure = startupFailure() throw new AggregateError( - [thrown(error), thrown(disposeError)], - 'subagent-claude-code: startup failed and query cleanup also failed', + [ + failure, + new ClaudeCodeFailure({ + stage: 'teardown', + category: 'unknown', + }, thrown(disposeError)), + ], + `${failure.message}; startup cleanup also failed`, ) } } - // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited. - if (cancelledBeforeCleanup || request.signal.aborted) { + if (cancelledBeforeCleanup || isAborted(request.signal)) { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw thrown(error) + throw startupFailure() } const publishedQuery = query const publishedChild = child const result = settleRunResult({ - attempt: () => consumeClaudeQuery(publishedQuery, () => { - captureDiagnostic(unattendedDiagnostic( - spec.permissionMode, - 'tool permission', - 'denied', - 'Claude Code denied the request before an interactive prompt', - )) - }), + attempt: async () => { + try { + return await consumeClaudeQuery(publishedQuery, () => { + capturePermissionDiagnostic(unattendedDiagnostic( + spec.permissionMode, + 'tool permission', + 'denied', + 'Claude Code denied the request before an interactive prompt', + )) + }) + } catch (error: unknown) { + await Promise.resolve() + const facts = error instanceof ClaudeCodeFailure + ? { ...error.facts, outcome: processOutcome } + : processOutcome === undefined + ? { stage: 'query-run', category: 'unknown' } as const + : { + stage: 'process', + category: 'process-exit', + outcome: processOutcome, + } as const + failureDetail = failureDiagnostic(facts) + throw error instanceof ClaudeCodeFailure + ? error + : new ClaudeCodeFailure(facts, thrown(error)) + } + }, collectOutput: () => [], - collectDiagnostic: () => diagnostic, + collectDiagnostic, cancelled: () => controller.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index a2e7111ece..5e55e9c521 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -21,7 +21,11 @@ import { Context } from '@deepseek-ai/cordis' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import type { + SubprocessHandle, + SubprocessOutcome, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' import type { ClaudeCodePermissionMode } from '../src/run.ts' @@ -32,6 +36,7 @@ import { } from './messages-fixture.ts' const observedSdkMessages = vi.hoisted((): SDKMessage[] => []) +const sdkTestOverrides = vi.hoisted((): { maxTurns?: number } => ({})) vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => { const actual = await importOriginal< @@ -39,8 +44,13 @@ vi.mock('@anthropic-ai/claude-agent-sdk', async (importOriginal) => { >() return { ...actual, - query(options: Parameters[0]): Query { - const query = actual.query(options) + query(params: Parameters[0]): Query { + const query = actual.query(sdkTestOverrides.maxTurns === undefined + ? params + : { + ...params, + options: { ...params.options, maxTurns: sdkTestOverrides.maxTurns }, + }) // Observe the real SDK stream without replacing its protocol or CLI. return new Proxy(query, { get(target, property) { @@ -112,6 +122,7 @@ afterEach(async () => { await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } observedSdkMessages.length = 0 + delete sdkTestOverrides.maxTurns }) interface RealHarness { @@ -216,6 +227,17 @@ async function expectQuiescent( } } +function expectedProcessFailure(outcome: SubprocessOutcome): string { + const fields = [ + 'product: Claude Code', + 'stage: process', + 'category: process-exit', + ] + if (outcome.exitCode !== null) fields.push(`exit code: ${outcome.exitCode}`) + if (outcome.signal !== null) fields.push(`signal: ${outcome.signal}`) + return `Product subagent failure (${fields.join('; ')})` +} + function startRequest( harness: RealHarness, prompt: string, @@ -294,14 +316,49 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 await expectQuiescent(harness.handles) }) - it('maps a real CLI process failure to error', async () => { + it('maps a real SDK max-turns result to safe query-run facts', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-claude-code-max-turns-')) + roots.push(root) + const target = join(root, 'max-turns.txt') + sdkTestOverrides.maxTurns = 1 + const { harness, fixture } = await realHarness({ + kind: 'tool-use', + toolName: 'Write', + input: { + file_path: target, + content: 'real-sdk-max-turns', + }, + }, 'bypassPermissions') + const run = await startRequest(harness, 'Exercise the SDK max-turns result.') + const result = await run.result + expect(observedSdkMessages + .filter(message => message.type === 'result') + .map(message => message.subtype)).toEqual(['error_max_turns']) + expect(result).toMatchObject({ + output: [], + stopReason: 'error', + }) + expect(result.diagnostic).toContain( + 'product: Claude Code; stage: query-run; category: error_max_turns', + ) + expect(readFileSync(target, 'utf8')).toBe('real-sdk-max-turns') + expect(result.diagnostic).not.toContain(target) + expect(result.diagnostic).not.toContain('real-sdk-max-turns') + await run.dispose() + expect(fixture.requests).toHaveLength(1) + await expectQuiescent(harness.handles) + }) + + it('maps a real CLI process failure to its exit outcome', async () => { const { harness, fixture } = await realHarness({ kind: 'hold' }) const run = await startRequest(harness, 'Exercise the failure path.') await fixture.requestStarted expect(harness.handles).toHaveLength(1) harness.handles[0]!.terminate() + const outcome = await harness.handles[0]!.done await expect(run.result).resolves.toEqual({ output: [], + diagnostic: expectedProcessFailure(outcome), stopReason: 'error', }) await run.dispose() @@ -330,10 +387,11 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 }, { timeout: 30_000 }) expect(existsSync(target)).toBe(false) harness.handles[0]!.terminate() + const outcome = await harness.handles[0]!.done const result = await run.result expect(result).toEqual({ output: [], - diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + diagnostic: `${expectedProcessFailure(outcome)}\nClaude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt`, stopReason: 'error', }) expect(result.diagnostic).not.toContain(target) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index b5be0987ca..372ed6269b 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -192,6 +192,25 @@ function failure( } as SDKResultMessage } +function expectedFailureDiagnostic( + stage: 'query-start' | 'query-run' | 'process' | 'teardown', + category: string, + outcome?: Partial, +): string { + const fields = [ + 'product: Claude Code', + `stage: ${stage}`, + `category: ${category}`, + ] + if (outcome?.exitCode !== null && outcome?.exitCode !== undefined) { + fields.push(`exit code: ${outcome.exitCode}`) + } + if (outcome?.signal !== null && outcome?.signal !== undefined) { + fields.push(`signal: ${outcome.signal}`) + } + return `Product subagent failure (${fields.join('; ')})` +} + function permissionDenied(): SDKPermissionDeniedMessage { return { type: 'system', @@ -397,7 +416,18 @@ describe('task admission and package contracts', () => { resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) await expect(ctx.subagents.start('claude-code', request())) - .rejects.toThrow('claude missing from PATH') + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + expect(queryMock).not.toHaveBeenCalled() + + const resolutionAbort = new AbortController() + resolveExecutable.mockImplementationOnce(async () => { + resolutionAbort.abort(new Error('parent cancelled executable resolution')) + throw new Error('SECRET_TOKEN from executable resolution') + }) + await expect(ctx.subagents.start( + 'claude-code', + request(undefined, resolutionAbort.signal), + )).rejects.toThrow('aborted before SDK startup') expect(queryMock).not.toHaveBeenCalled() const run = await ctx.subagents.start('claude-code', request()) @@ -405,11 +435,13 @@ describe('task admission and package contracts', () => { child.stdout.end() await expect(run.result).resolves.toEqual({ output: [], + diagnostic: expectedFailureDiagnostic('query-run', 'missing-result'), stopReason: 'error', }) - expect(warn).toHaveBeenCalledWith(expect.stringContaining( - 'subagent-claude-code: child run failed (error):', - )) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('subagent-claude-code: child run failed (error):'), + expect.any(Error), + ) expect(resolveExecutable).toHaveBeenCalledWith( 'claude', expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }), @@ -711,17 +743,34 @@ describe('query options and result mapping', () => { it('accepts only a non-error success with a non-blank final result', () => { expect(successfulResult(success('exact final'))).toBe('exact final') expect(() => successfulResult(success('answer', true))) - .toThrow('marked as an error') + .toThrow(expectedFailureDiagnostic('query-run', 'invalid-success')) expect(() => successfulResult(success(' \n '))) - .toThrow('contained no answer') - expect(() => successfulResult(failure( + .toThrow(expectedFailureDiagnostic('query-run', 'invalid-success')) + const sdkFailure = () => successfulResult(failure( 'error_during_execution', - ['first', 'second'], - ))).toThrow('first; second') + ['SECRET_TOKEN', '/private/secret.txt'], + )) + expect(sdkFailure).toThrow(expectedFailureDiagnostic( + 'query-run', + 'error_during_execution', + )) + expect(sdkFailure).not.toThrow('SECRET_TOKEN') + expect(sdkFailure).not.toThrow('/private/secret.txt') expect(() => successfulResult(failure( 'error_max_turns', [], - ))).toThrow('error_max_turns') + ))).toThrow(expectedFailureDiagnostic('query-run', 'error_max_turns')) + + const unknown = { + type: 'result', + subtype: 'future_failure', + is_error: true, + errors: ['SECRET_TOKEN'], + } as unknown as SDKResultMessage + expect(() => successfulResult(unknown)) + .toThrow(expectedFailureDiagnostic('query-run', 'unknown')) + expect(() => successfulResult(unknown)).not.toThrow('future_failure') + expect(() => successfulResult(unknown)).not.toThrow('SECRET_TOKEN') }) it('consumes the complete stream and keeps the latest strict success', async () => { @@ -736,7 +785,7 @@ describe('query options and result mapping', () => { }) await expect(consumeClaudeQuery( queryFrom([{ type: 'system', subtype: 'init' } as SDKMessage]), - )).rejects.toThrow('ended without a result') + )).rejects.toThrow(expectedFailureDiagnostic('query-run', 'missing-result')) const onPermissionDenied = vi.fn() await expect(consumeClaudeQuery(queryFrom([ @@ -790,6 +839,7 @@ describe('run publication, cancellation, and settlement', () => { ) await expect(run.result).resolves.toEqual({ output: [], + diagnostic: expectedFailureDiagnostic('query-run', subtype), stopReason: 'error', }) expect(onError).toHaveBeenCalledWith( @@ -809,7 +859,7 @@ describe('run publication, cancellation, and settlement', () => { const result = await run.result expect(result).toEqual({ output: [], - diagnostic: 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + diagnostic: `${expectedFailureDiagnostic('query-run', 'error_during_execution')}\nClaude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt`, stopReason: 'error', }) expect(result.diagnostic).not.toContain('SECRET_TOKEN') @@ -851,6 +901,10 @@ describe('run publication, cancellation, and settlement', () => { }) await expect(failed.result).resolves.toEqual({ output: [], + diagnostic: expectedFailureDiagnostic( + 'query-run', + 'error_during_execution', + ), stopReason: 'error', }) await Promise.all([completed.dispose(), failed.dispose()]) @@ -864,26 +918,70 @@ describe('run publication, cancellation, and settlement', () => { const run = await startClaudeCodeRun(request(), fixture.spec) await expect(run.result).resolves.toEqual({ output: [], + diagnostic: expectedFailureDiagnostic('query-run', 'unknown'), stopReason: 'error', }) await run.dispose() }) - it('maps invalid success and missing result to error', async () => { - for (const messages of [ - [success('answer', true)], - [success('')], - [{ type: 'system', subtype: 'init' } as SDKMessage], - ]) { + it('maps invalid success and missing result to fixed query-run facts', async () => { + for (const [messages, category] of [ + [[success('answer', true)], 'invalid-success'], + [[success('')], 'invalid-success'], + [[{ type: 'system', subtype: 'init' } as SDKMessage], 'missing-result'], + ] as const) { const fixture = fakeRun(messages) const run = await startClaudeCodeRun(request(), fixture.spec) - await expect(run.result).resolves.toMatchObject({ + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('query-run', category), stopReason: 'error', }) await run.dispose() } }) + it('reports an early process exit with independent code and signal facts', async () => { + const outcomes: SubprocessOutcome[] = [ + { exitCode: 23, signal: null }, + { exitCode: null, signal: 'SIGABRT' }, + { exitCode: 23, signal: 'SIGABRT' }, + { exitCode: null, signal: null }, + ] + for (const outcome of outcomes) { + const child = fakeChild() + async function* stream(): AsyncGenerator { + child.settle(outcome) + await Promise.resolve() + throw new Error('SECRET_TOKEN from process transport') + } + queryMock.mockImplementation(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return Object.assign(stream(), { close: vi.fn() }) as unknown as Query + }) + const run = await startClaudeCodeRun(request(), { + cwd: '/workspace', + executable: '/native/claude', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + }) + const result = await run.result + expect(result).toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic( + 'process', + 'process-exit', + outcome, + ), + stopReason: 'error', + }) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + await run.dispose() + } + }) + it('gives local cancellation precedence and isolates overlapping controllers', async () => { const firstChild = fakeChild() const secondChild = fakeChild() @@ -974,7 +1072,7 @@ describe('run publication, cancellation, and settlement', () => { ) await expect(startClaudeCodeRun(request(), { ...unused.spec, - })).rejects.toThrow('did not publish a controllable') + })).rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) expect(noChildClose).toHaveBeenCalledOnce() const closeFailure = vi.fn(() => { throw new Error('close boom') }) @@ -984,6 +1082,8 @@ describe('run publication, cancellation, and settlement', () => { const noChild = startClaudeCodeRun(request(), { ...unused.spec, }) + await expect(noChild) + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) await expect(noChild).rejects.toBeInstanceOf(AggregateError) const startupAbort = new AbortController() @@ -1006,12 +1106,40 @@ describe('run publication, cancellation, and settlement', () => { expect(abortedClose).toHaveBeenCalledOnce() expect(abortedChild.terminate).toHaveBeenCalledOnce() + const cleanupAbort = new AbortController() + const cleanupFailedChild = fakeChild({ + waitForExitError: new Error('SECRET_TOKEN cleanup wait failure'), + }) + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + cleanupAbort.abort(new Error('startup cancelled')) + return queryFrom([]) + }) + const cancelledCleanupFailure = startClaudeCodeRun( + request(undefined, cleanupAbort.signal), + { + ...unused.spec, + spawn: () => cleanupFailedChild.handle, + }, + ) + await expect(cancelledCleanupFailure) + .rejects.toBeInstanceOf(AggregateError) + await expect(cancelledCleanupFailure) + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(cancelledCleanupFailure) + .rejects.not.toThrow('SECRET_TOKEN') + queryMock.mockImplementationOnce(() => { throw new Error('query failed before resource creation') }) - await expect(startClaudeCodeRun(request(), { + const queryFailure = startClaudeCodeRun(request(), { ...unused.spec, - })).rejects.toThrow('query failed before resource creation') + }) + await expect(queryFailure) + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(queryFailure).rejects.not.toThrow( + 'query failed before resource creation', + ) const spawned = fakeChild() const spawnSpecs: SubprocessSpawnSpec[] = [] @@ -1019,6 +1147,7 @@ describe('run publication, cancellation, and settlement', () => { queryMock.mockImplementationOnce(({ options }) => { factoryController = options.abortController options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + spawned.settle({ exitCode: 17, signal: 'SIGABRT' }) throw new Error('query construction failed') }) const factoryFailure = startClaudeCodeRun(request(), { @@ -1028,7 +1157,12 @@ describe('run publication, cancellation, and settlement', () => { return spawned.handle }, }) - await expect(factoryFailure).rejects.toThrow('query construction failed') + await expect(factoryFailure).rejects.toThrow(expectedFailureDiagnostic( + 'query-start', + 'unknown', + { exitCode: 17, signal: 'SIGABRT' }, + )) + await expect(factoryFailure).rejects.not.toThrow('query construction failed') expect(spawnSpecs).toHaveLength(1) expect(factoryController?.signal.aborted).toBe(true) expect(spawned.terminate).toHaveBeenCalledOnce() @@ -1038,8 +1172,10 @@ describe('run publication, cancellation, and settlement', () => { doneError: new Error('spawn failed'), }) const failed = fakeRun([], undefined, failedSpawn) - await expect(startClaudeCodeRun(request(), failed.spec)) - .rejects.toBeInstanceOf(AggregateError) + const failedStartup = startClaudeCodeRun(request(), failed.spec) + await expect(failedStartup) + .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(failedStartup).rejects.toBeInstanceOf(AggregateError) expect(failed.close).toHaveBeenCalledOnce() }) }) @@ -1080,10 +1216,16 @@ describe('query and process disposal', () => { waitForExitError: new Error('wait boom'), }) const closeFailure = vi.fn(() => { throw new Error('close boom') }) - await expect(disposeClaudeCodeChild( + const waitAndClose = disposeClaudeCodeChild( { close: closeFailure }, waitFailure.handle, - )).rejects.toBeInstanceOf(AggregateError) + ) + await expect(waitAndClose).rejects.toThrow(expectedFailureDiagnostic( + 'teardown', + 'unknown', + { exitCode: 0, signal: null }, + )) + await expect(waitAndClose).rejects.toBeInstanceOf(AggregateError) expect(waitFailure.terminate).toHaveBeenCalledOnce() const doneFailure = fakeChild({ @@ -1093,15 +1235,18 @@ describe('query and process disposal', () => { await expect(disposeClaudeCodeChild( { close: vi.fn() }, doneFailure.handle, - )).rejects.toThrow('spawn boom') + )).rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) const both = fakeChild({ pid: -1, doneError: new Error('spawn boom'), }) - await expect(disposeClaudeCodeChild( + const bothFailures = disposeClaudeCodeChild( { close: () => { throw new Error('close boom') } }, both.handle, - )).rejects.toBeInstanceOf(AggregateError) + ) + await expect(bothFailures) + .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) + await expect(bothFailures).rejects.toBeInstanceOf(AggregateError) }) }) diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 6d136cf2d0..acbb09f49f 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: 161159264ffadf32cc769d65f19caf6d74dc862d -README.zh.md: 561206ae56684329ca54b1a524b224a73e4f30b3 +README.md: f976466610f37a4744c5a2f1e2fff590ea7587b9 +README.zh.md: bcd4e4cc7f1dc56db7f278f4ff32c148d1d6e872 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 161159264f..f976466610 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -64,7 +64,7 @@ Both in-process delegation paths fix the child's permission scope at the delegat `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?, diagnostic?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. A provider may add a safe `diagnostic` to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. The field is not assistant output: consumers present it separately, and it does not enter `subagent/end.lastAssistantMessage`. `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`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the terminal result contract). +`SubagentRun.result` resolves to `{ output, structured?, diagnostic?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. A provider may add a safe `diagnostic` to a non-completed result after removing tool inputs, file contents, environment values, credentials, and raw protocol payloads and limiting the complete text to 4096 UTF-8 bytes. The common result type does not define provider categories or lifecycle stages: an out-of-process provider may derive fixed display text from its version-pinned structured product facts and an observed process outcome, while consumers render that text without parsing it. The field is not assistant output: consumers present it separately, and it does not enter `subagent/end.lastAssistantMessage`. `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`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the terminal 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 561206ae56..bcd4e4cc7f 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -64,7 +64,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且在任何失败路径上都必须取消、回滚并使尚未发布的资源完全停稳。兑现后,run 的所有权转移给调用方;调用方必须在每条路径上调用 `dispose()`。剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage`。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 +`SubagentRun.result` 兑现为 `{ output, structured?, diagnostic?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。提供方可以为非完成结果附加安全的 `diagnostic`:它会先排除工具输入、文件内容、环境值、凭证与原始协议载荷,并把完整文本限制在 4096 个 UTF-8 字节以内。共享结果类型不定义提供方类别或生命周期阶段:进程外提供方可以从锁定版本产品提供的结构化事实与已观测的进程结果派生固定展示文本,而消费方只负责原样呈现,不解析该文本。该字段不是 assistant 输出;消费方会将它分开呈现,它也不会进入 `subagent/end.lastAssistantMessage`。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。result 的拒绝只通过 `result` 本身报告;只有独立的资源释放失败,才会使 `dispose()` 被拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(终态结果约定归 [`SubagentResult`](../../../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 会话,其一次性运行不会进入基于追踪的枚举结果。 From 605b399a8f7de3a7f475dc47f434c2439ce8ff3b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 02:45:42 +0800 Subject: [PATCH 056/137] refactor(subagent): simplify Claude failure fact ownership --- .../subagent-claude-code/src/process.ts | 8 +++ .../subagent/subagent-claude-code/src/run.ts | 57 +++++++++++-------- .../tests/subagent-claude-code.spec.ts | 21 +++++++ 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 1e2a259ca2..13f5eb132c 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -14,6 +14,7 @@ import type { import { scrubbedParentEnv, type SubprocessHandle, + type SubprocessOutcome, type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' @@ -83,6 +84,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { private readonly events = new EventEmitter() private exitCodeValue: number | null = null private signalCodeValue: NodeJS.Signals | null = null + private outcomeValue: SubprocessOutcome | undefined private killRequested = false /** @@ -98,6 +100,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { this.events.on('error', () => {}) void child.done.then( (outcome) => { + this.outcomeValue = outcome this.exitCodeValue = outcome.exitCode this.signalCodeValue = outcome.signal this.events.emit('exit', outcome.exitCode, outcome.signal) @@ -123,6 +126,11 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { return this.signalCodeValue } + /** Exact managed-process outcome after exit, or undefined while running. */ + get outcome(): SubprocessOutcome | undefined { + return this.outcomeValue + } + /** * Route the SDK's termination request to the tree-scoped process owner. * @param _signal - SDK-selected signal; the shared seam owns its escalation ladder. diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 3e5b3c0905..8fc38e5358 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -168,10 +168,6 @@ function thrown(value: unknown): Error { /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) } - -function isAborted(signal: AbortSignal): boolean { - return signal.aborted -} /* jscpd:ignore-end */ /** @@ -308,14 +304,17 @@ export async function disposeClaudeCodeChild( * Build the fixed official SDK options for one one-shot provider run. * @param spec - Workspace, environment, process service, and disposal policy. * @param controller - per-run cancellation owner. - * @param capture - receives the real managed child synchronously from the SDK hook. + * @param capture - receives the shared child and SDK-facing process synchronously. * @param captureDiagnostic - receives safe facts from unattended interaction callbacks. * @returns options that inherit native settings while disabling persistence and user questions. */ export function claudeQueryOptions( spec: ClaudeCodeRunSpec, controller: AbortController, - capture: (child: SubprocessHandle) => void, + capture: ( + child: SubprocessHandle, + process: ManagedClaudeCodeProcess, + ) => void, captureDiagnostic: (diagnostic: string) => void, ): Options { return { @@ -365,8 +364,9 @@ export function claudeQueryOptions( supportedDialogKinds: SUPPORTED_UNATTENDED_DIALOG_KINDS, spawnClaudeCodeProcess: (options: SpawnOptions) => { const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs)) - capture(child) - return new ManagedClaudeCodeProcess(child) + const process = new ManagedClaudeCodeProcess(child) + capture(child, process) + return process }, } } @@ -397,21 +397,23 @@ export async function startClaudeCodeRun( let child: SubprocessHandle | undefined let query: Query | undefined - let processOutcome: SubprocessOutcome | undefined - let failureDetail: string | undefined - let permissionDetail: string | undefined + let managedProcess: ManagedClaudeCodeProcess | undefined + let diagnostic: string | undefined const capturePermissionDiagnostic = (value: string): void => { - permissionDetail = value + diagnostic = value } - const collectDiagnostic = (): string => [failureDetail, permissionDetail] - .filter((value): value is string => value !== undefined) - .join('\n') - const captureChild = (captured: SubprocessHandle): void => { + const prependFailureDiagnostic = (facts: ClaudeCodeFailureFacts): void => { + const failure = failureDiagnostic(facts) + diagnostic = diagnostic === undefined + ? failure + : `${failure}\n${diagnostic}` + } + const captureChild = ( + captured: SubprocessHandle, + process: ManagedClaudeCodeProcess, + ): void => { child = captured - void captured.done.then( - (outcome: SubprocessOutcome) => { processOutcome = outcome }, - () => undefined, - ) + managedProcess = process } try { query = officialQuery({ @@ -435,7 +437,7 @@ export async function startClaudeCodeRun( request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = controller.signal.aborted await Promise.resolve() - const startupOutcome = processOutcome + const startupOutcome = managedProcess?.outcome const startupFacts = { stage: 'query-start', category: 'unknown', @@ -473,7 +475,12 @@ export async function startClaudeCodeRun( ) } } - if (cancelledBeforeCleanup || isAborted(request.signal)) { + if (cancelledBeforeCleanup) { + throw new Error('subagent-claude-code: request was aborted before SDK startup') + } + try { + request.signal.throwIfAborted() + } catch { throw new Error('subagent-claude-code: request was aborted before SDK startup') } throw startupFailure() @@ -493,7 +500,7 @@ export async function startClaudeCodeRun( )) }) } catch (error: unknown) { - await Promise.resolve() + const processOutcome = managedProcess?.outcome const facts = error instanceof ClaudeCodeFailure ? { ...error.facts, outcome: processOutcome } : processOutcome === undefined @@ -503,14 +510,14 @@ export async function startClaudeCodeRun( category: 'process-exit', outcome: processOutcome, } as const - failureDetail = failureDiagnostic(facts) + prependFailureDiagnostic(facts) throw error instanceof ClaudeCodeFailure ? error : new ClaudeCodeFailure(facts, thrown(error)) } }, collectOutput: () => [], - collectDiagnostic, + collectDiagnostic: () => diagnostic, cancelled: () => controller.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 372ed6269b..7325456afd 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -553,6 +553,7 @@ describe('official spawn projection', () => { expect(process.killed).toBe(false) expect(process.exitCode).toBeNull() expect(process.signalCode).toBeNull() + expect(process.outcome).toBeUndefined() const exit = vi.fn() const once = vi.fn() @@ -572,6 +573,7 @@ describe('official spawn projection', () => { expect(once).toHaveBeenCalledOnce() expect(removed).not.toHaveBeenCalled() expect(process.signalCode).toBe('SIGTERM') + expect(process.outcome).toEqual({ exitCode: null, signal: 'SIGTERM' }) expect(process.kill('SIGTERM')).toBe(false) }) @@ -598,6 +600,7 @@ describe('official spawn projection', () => { await nextTask() expect(process.exitCode).toBe(7) expect(process.signalCode).toBeNull() + expect(process.outcome).toEqual({ exitCode: 7, signal: null }) expect(process.kill('SIGTERM')).toBe(false) }) }) @@ -1167,6 +1170,24 @@ describe('run publication, cancellation, and settlement', () => { expect(factoryController?.signal.aborted).toBe(true) expect(spawned.terminate).toHaveBeenCalledOnce() + const cleanupRaceAbort = new AbortController() + const cleanupRaceChild = fakeChild({ exitOnTerminate: false }) + queryMock.mockImplementationOnce(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + throw new Error('query failed before cleanup wait') + }) + const cleanupRace = startClaudeCodeRun( + request(undefined, cleanupRaceAbort.signal), + { + ...unused.spec, + spawn: () => cleanupRaceChild.handle, + }, + ) + await nextTask() + cleanupRaceAbort.abort(new Error('cancelled during cleanup')) + cleanupRaceChild.settle() + await expect(cleanupRace).rejects.toThrow('aborted before SDK startup') + const failedSpawn = fakeChild({ pid: -1, doneError: new Error('spawn failed'), From a3a9f86d8968876020bd597ea5e39db63525065f Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 02:54:20 +0800 Subject: [PATCH 057/137] refactor(subagent): derive Claude exit projections --- packages/subagent/subagent-claude-code/src/process.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 13f5eb132c..cba2f61441 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -82,8 +82,6 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { readonly stdin readonly stdout private readonly events = new EventEmitter() - private exitCodeValue: number | null = null - private signalCodeValue: NodeJS.Signals | null = null private outcomeValue: SubprocessOutcome | undefined private killRequested = false @@ -101,8 +99,6 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { void child.done.then( (outcome) => { this.outcomeValue = outcome - this.exitCodeValue = outcome.exitCode - this.signalCodeValue = outcome.signal this.events.emit('exit', outcome.exitCode, outcome.signal) }, (error: unknown) => { @@ -118,12 +114,12 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { /** Direct-child exit code, or null while running or after signal exit. */ get exitCode(): number | null { - return this.exitCodeValue + return this.outcomeValue?.exitCode ?? null } /** Direct-child terminating signal, if any. */ get signalCode(): NodeJS.Signals | null { - return this.signalCodeValue + return this.outcomeValue?.signal ?? null } /** Exact managed-process outcome after exit, or undefined while running. */ @@ -139,8 +135,7 @@ export class ManagedClaudeCodeProcess implements SpawnedProcess { kill(_signal: NodeJS.Signals): boolean { if ( this.killRequested - || this.exitCodeValue !== null - || this.signalCodeValue !== null + || this.outcomeValue !== undefined ) { return false } From 77211b1c262b0bdfe147bfa2ed67aea958782f8e Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 03:06:34 +0800 Subject: [PATCH 058/137] fix(subagent): expose startup cleanup failure facts --- .../subagent/subagent-claude-code/src/run.ts | 19 +++++++++---------- .../tests/subagent-claude-code.spec.ts | 9 +++++++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 8fc38e5358..2604e59397 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -453,9 +453,10 @@ export async function startClaudeCodeRun( await disposeClaudeCodeChild(query, child) } catch (disposeError: unknown) { const failure = startupFailure() + const cleanupFailure = thrown(disposeError) throw new AggregateError( - [failure, thrown(disposeError)], - `${failure.message}; startup cleanup also failed`, + [failure, cleanupFailure], + `${failure.message}; ${cleanupFailure.message}`, ) } } else if (query !== undefined) { @@ -463,15 +464,13 @@ export async function startClaudeCodeRun( query.close() } catch (disposeError: unknown) { const failure = startupFailure() + const cleanupFailure = new ClaudeCodeFailure({ + stage: 'teardown', + category: 'unknown', + }, thrown(disposeError)) throw new AggregateError( - [ - failure, - new ClaudeCodeFailure({ - stage: 'teardown', - category: 'unknown', - }, thrown(disposeError)), - ], - `${failure.message}; startup cleanup also failed`, + [failure, cleanupFailure], + `${failure.message}; ${cleanupFailure.message}`, ) } } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 7325456afd..786f19d37c 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1087,6 +1087,9 @@ describe('run publication, cancellation, and settlement', () => { }) await expect(noChild) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(noChild).rejects.toThrow( + `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`, + ) await expect(noChild).rejects.toBeInstanceOf(AggregateError) const startupAbort = new AbortController() @@ -1129,6 +1132,9 @@ describe('run publication, cancellation, and settlement', () => { .rejects.toBeInstanceOf(AggregateError) await expect(cancelledCleanupFailure) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(cancelledCleanupFailure).rejects.toThrow( + `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown', { exitCode: 0, signal: null })}`, + ) await expect(cancelledCleanupFailure) .rejects.not.toThrow('SECRET_TOKEN') @@ -1196,6 +1202,9 @@ describe('run publication, cancellation, and settlement', () => { const failedStartup = startClaudeCodeRun(request(), failed.spec) await expect(failedStartup) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(failedStartup).rejects.toThrow( + `${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`, + ) await expect(failedStartup).rejects.toBeInstanceOf(AggregateError) expect(failed.close).toHaveBeenCalledOnce() }) From fded16f68881407c261b87480a4cdb7500c73a1a Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 03:33:58 +0800 Subject: [PATCH 059/137] refactor(subagent): use parent abort as startup authority --- .../subagent/subagent-claude-code/src/run.ts | 4 -- .../tests/real-product.spec.ts | 37 ++++++++++++------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 2604e59397..5ea41d9bac 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -435,7 +435,6 @@ export async function startClaudeCodeRun( } } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) - const cancelledBeforeCleanup = controller.signal.aborted await Promise.resolve() const startupOutcome = managedProcess?.outcome const startupFacts = { @@ -474,9 +473,6 @@ export async function startClaudeCodeRun( ) } } - if (cancelledBeforeCleanup) { - throw new Error('subagent-claude-code: request was aborted before SDK startup') - } try { request.signal.throwIfAborted() } catch { diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 5e55e9c521..344515d4cf 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -227,11 +227,15 @@ async function expectQuiescent( } } -function expectedProcessFailure(outcome: SubprocessOutcome): string { +function expectedFailure( + stage: 'query-run' | 'process', + category: 'error_during_execution' | 'process-exit', + outcome: SubprocessOutcome, +): string { const fields = [ 'product: Claude Code', - 'stage: process', - 'category: process-exit', + `stage: ${stage}`, + `category: ${category}`, ] if (outcome.exitCode !== null) fields.push(`exit code: ${outcome.exitCode}`) if (outcome.signal !== null) fields.push(`signal: ${outcome.signal}`) @@ -356,11 +360,13 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 expect(harness.handles).toHaveLength(1) harness.handles[0]!.terminate() const outcome = await harness.handles[0]!.done - await expect(run.result).resolves.toEqual({ - output: [], - diagnostic: expectedProcessFailure(outcome), - stopReason: 'error', - }) + const result = await run.result + expect(result.output).toEqual([]) + expect(result.stopReason).toBe('error') + expect([ + expectedFailure('process', 'process-exit', outcome), + expectedFailure('query-run', 'error_during_execution', outcome), + ]).toContain(result.diagnostic) await run.dispose() expect(fixture.requests).toHaveLength(1) expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey) @@ -389,11 +395,16 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 harness.handles[0]!.terminate() const outcome = await harness.handles[0]!.done const result = await run.result - expect(result).toEqual({ - output: [], - diagnostic: `${expectedProcessFailure(outcome)}\nClaude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt`, - stopReason: 'error', - }) + expect(result.output).toEqual([]) + expect(result.stopReason).toBe('error') + const diagnosticLines = result.diagnostic?.split('\n') ?? [] + expect([ + expectedFailure('process', 'process-exit', outcome), + expectedFailure('query-run', 'error_during_execution', outcome), + ]).toContain(diagnosticLines[0]) + expect(diagnosticLines[1]).toBe( + 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', + ) expect(result.diagnostic).not.toContain(target) expect(result.diagnostic).not.toContain('SECRET_TOKEN') await run.dispose() From 6bae03dd692d777c84552e1d21ed01185dc9a582 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 03:40:11 +0800 Subject: [PATCH 060/137] fix(subagent): preserve Codex failure facts --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 6 +- ...ude-code-and-codex-subagent-backends.zh.md | 6 +- ...agent-noninteractive-permissions.i18n.yaml | 4 +- ...uct-subagent-noninteractive-permissions.md | 4 +- ...-subagent-noninteractive-permissions.zh.md | 4 +- ...8-product-subagent-failure-facts.i18n.yaml | 4 +- ...26-08-18-product-subagent-failure-facts.md | 23 +- ...08-18-product-subagent-failure-facts.zh.md | 23 +- .../fixtures/subagent-result-diagnostic.ts | 38 +- .../input.json | 2 +- .../replay.override.json | 42 +- .../session.jsonl | 89 ++-- .../subagent/subagent-codex/README.i18n.yaml | 4 +- packages/subagent/subagent-codex/README.md | 12 +- packages/subagent/subagent-codex/README.zh.md | 12 +- packages/subagent/subagent-codex/src/run.ts | 198 +++++++-- packages/subagent/subagent-codex/src/wire.ts | 159 +++++-- .../subagent-codex/tests/real-product.spec.ts | 92 +++- .../tests/subagent-codex.spec.ts | 412 ++++++++++++++++-- 20 files changed, 956 insertions(+), 182 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 9c2c6dd814..a3f77cb935 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 829dca8dbd79b408fcfcfd1d88490d793ad4b5ee -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 063bd8c9a59a1b1eccf4893a00efe12a80fbe03f +2026-08-04-claude-code-and-codex-subagent-backends.md: a75c8c50144ca71190fc8ad16f623b062bd3f492 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 21e05fb8ede1a7e2a58182687648e9513a634d96 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 829dca8dbd..a75c8c5014 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -38,11 +38,11 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. A failed turn with `error.codexErrorInfo: "contextWindowExceeded"` becomes `max-tokens`. A completed turn without an answer, every other failed or interrupted remote turn, malformed required fields in a recognized app-server frame, protocol closure, early process exit, or unknown server request becomes `error`; a permission-related error may additionally carry the shared safe diagnostic. This version has no native refusal terminal and therefore produces no `refusal`. Local cancellation wins its race and remains `aborted` without permission detail. +`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The Provider preserves every Codex 0.147.0 string and object `codexErrorInfo` variant in the shared safe diagnostic, including numeric HTTP status on the four connection/stream variants while omitting `turnKind`. It derives `initialize`, `thread-start`, `turn-start`, `turn`, `process`, and `teardown` from the current operation, preserves available exit code and signal, and uses `unknown` outside the fixed schema. `contextWindowExceeded` remains `max-tokens`; every other category remains `error`, `cyberPolicy` does not become `refusal`, and local cancellation remains `aborted` without a failure diagnostic. For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. -An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()`. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Result failure and teardown failure stay independently observable. +An unpublished startup failure closes the wire, terminates the acquired process tree, waits for exit, detaches the stderr observer, and then rejects `start()` with its fixed operation stage. Published disposal best-effort interrupts a known turn, closes the wire, ends stdin, invokes the shared termination escalation, waits for whole-tree exit, and detaches the observer. Independent cleanup failure reports `teardown`; when startup and rollback both fail, the aggregate's top message retains both safe stage lines while the underlying causes remain internal. Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-compatible endpoint speaks Chat Completions. The credentialed Codex e2e therefore uses a loopback-only, test-private bridge for one no-tool nonce request: real Codex sends Responses to the bridge, the bridge forwards the received bearer credential and extracted task to the fixed official DeepSeek endpoint, and it wraps the real text in the minimal Responses SSE lifecycle. The bridge is neither a production proxy nor evidence that Codex connects to DeepSeek Chat Completions natively. @@ -62,7 +62,7 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Loader tier boots the README-shaped explicit Profile configuration, verifies both fixed one-shot tools expose optional background scheduling alongside generic Job controls, and starts neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, and whole-tree exit. Production still supplies `codex` on `PATH`. +The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Generated schema evidence and package tests pin all sixteen error-info variants, HTTP status, six stages, process outcomes, stop-reason mapping, unknown fallback, sanitization, permission ordering, cancellation, concurrency, and cleanup aggregation. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, a real `internalServerError`, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, process/protocol failure, and whole-tree exit. Production still supplies `codex` on `PATH`. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 063bd8c9a5..21e05fb8ed 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -38,11 +38,11 @@ fixed tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> product 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。带有 `error.codexErrorInfo: "contextWindowExceeded"` 的失败轮次会成为 `max-tokens`。轮次完成却没有答案、其他任何远端失败或中断轮次、已识别的 app-server 帧中必需字段格式错误、协议关闭、进程提前退出或未知的服务器请求,都会产生 `error`;权限相关错误可以额外携带共享安全诊断。本版本没有原生的拒绝终止状态,因此不会产生 `refusal`。本地取消在竞态中胜出并保持为 `aborted`,且不附带权限说明。 +`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。提供方会把 Codex 0.147.0 的每种字符串与对象 `codexErrorInfo` variant 保留进共享安全诊断;四种连接/stream variant 会保留数值 HTTP status,但不会公开 `turnKind`。它会从当前操作派生 `initialize`、`thread-start`、`turn-start`、`turn`、`process` 和 `teardown`,保留可用的退出码与信号,并对固定 schema 之外的值使用 `unknown`。`contextWindowExceeded` 仍是 `max-tokens`;其他类别仍是 `error`,`cyberPolicy` 不会成为 `refusal`,本地取消仍是 `aborted` 且不附带失败诊断。 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 -若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。结果失败与清理失败仍可彼此独立地观察。 +若启动在发布前失败,提供方会关闭协议连接、终止已获取的进程树、等待其退出、移除 stderr observer,然后用固定操作阶段拒绝 `start()`。对已发布的运行执行资源释放时,提供方会尽力中断已知轮次、关闭协议连接、结束标准输入、调用共享的逐级终止机制,等待整棵进程树退出,并移除 observer。独立清理失败会报告 `teardown`;启动与回滚同时失败时,聚合的顶层消息会保留两条安全阶段说明,而底层 cause 仍只在内部可见。 Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端点使用 Chat Completions。因此,带密钥 Codex e2e 会采用一个仅限回环、仅供测试内部使用的桥接层来处理一次不使用工具的随机数请求:真实 Codex 将 Responses 发送到桥接层,桥接层把收到的 Bearer 凭据与提取出的任务转发到固定的 DeepSeek 官方端点,再将真实文本包装进最小化的 Responses SSE(Server-Sent Events)生命周期。该桥接层既不是生产代理,也不能作为 Codex 原生连接 DeepSeek Chat Completions 的证据。 @@ -62,7 +62,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Loader 层级会启动 README 所示的显式 Profile 配置,在同一个上下文中验证两个固定一次性工具会与通用 Job 控制工具一起公开可选后台调度,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、测试拥有临时存储中的显式危险绕过写入、本地取消以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 +Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。生成的 schema 证据与包测试固定全部十六种 error-info variant、HTTP status、六个阶段、进程结果、终止原因映射、unknown 回退、脱敏、权限顺序、取消、并发与清理聚合。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、真实 `internalServerError`、测试拥有临时存储中的显式危险绕过写入、本地取消、进程/协议失败以及整棵进程树退出。生产环境仍提供 `codex`,并通过 `PATH` 解析。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml index 6b3e5d13ff..ac0970f3be 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.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-15-product-subagent-noninteractive-permissions.md -2026-08-15-product-subagent-noninteractive-permissions.md: 9327412cfdd306f7867f989c8cfc091941cb26e6 -2026-08-15-product-subagent-noninteractive-permissions.zh.md: a7992b58a14aff94f93397bfa0aa21b9fe727fb2 +2026-08-15-product-subagent-noninteractive-permissions.md: 8788fba3492e08090dd038fc3e7377f6bd1e29cd +2026-08-15-product-subagent-noninteractive-permissions.zh.md: 6f254930151abce23f04de4f354bf57ad81bba61 diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md index 9327412cfd..8788fba349 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.md @@ -46,7 +46,7 @@ The Provider overrides only those thread fields. `CODEX_HOME`, project configura `SubagentResult` carries an optional `diagnostic` for provider-authored, non-assistant failure detail. A Provider removes tool inputs, file contents, environment values, credentials, and raw protocol payloads before producing it. The shared out-of-process result boundary limits the complete text to 4096 UTF-8 bytes and marks truncation without splitting a character. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns non-permission product categories, lifecycle stages, and process outcomes carried by the same field. -Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. Claude Code places its structured failure line before the latest contributing permission fact; Codex retains its permission-only diagnostic in this product version. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`. +Each product's permission fact contains only the effective mode, request category, unattended decision, and a fixed safe reason. Claude Code derives those facts from SDK callbacks and `permission_denied` messages. Codex derives them from app-server requests, declined items, `sandboxError`, and two fixed permission signatures in a bounded stderr tail; raw stderr is still forwarded to the Host but never copied into the diagnostic. Both Providers place their structured failure line before the latest contributing permission fact. A successful result returns only the strict final answer; local cancellation remains `aborted` without permission detail; an unpublished startup failure still rejects `start()`. The Provider never adds either diagnostic fact to assistant output, structured output, or `subagent/end.lastAssistantMessage`. The foreground consumer presents the stop-reason headline, then the optional diagnostic, then any partial assistant output. The one-shot background adapter stores the same diagnostic beside the stop reason in the failed Job detail. Providers that omit the field retain their previous behavior. @@ -63,7 +63,7 @@ The foreground consumer presents the stop-reason headline, then the optional dia ## Verification -Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and keyless ACP snapshots record the shared foreground and Job diagnostic presentation while the model-facing product tool schemas contain no permission parameter. +Package tests pin every allowed and rejected Config value, the exact SDK and app-server field mappings, dangerous confirmations, unattended terminal responses, diagnostic sanitization and UTF-8 bound, successful-result omission, concurrent-run isolation, foreground ordering, Job detail, stderr observer disposal, and process cleanup. The real Claude Agent SDK/CLI fixture proves its safe default, restricted denial, explicit bypass, and whole-tree quiescence. The real Codex app-server fixture proves that thread-level `never` overrides ambient `on-request`, automatic review starts, dangerous bypass writes only inside suite-owned temporary storage, fixed stderr signatures produce safe diagnostics, and the wrapper/native tree exits. Loader composition proves non-default modes can be published without starting either product, and the keyless ACP snapshot records each product's failure diagnostic through foreground and Job presentation while the model-facing product tool schemas contain no permission parameter. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md index a7992b58a1..6f25493015 100644 --- a/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-15-product-subagent-noninteractive-permissions.zh.md @@ -46,7 +46,7 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 `SubagentResult` 携带可选的 `diagnostic`,用于提供方产生且不属于 assistant 内容的失败说明。提供方在生成它之前会排除工具输入、文件内容、环境值、凭证与原始协议载荷。共享的进程外结果边界会把完整文本限制在 4096 个 UTF-8 字节以内,并在不切断字符的前提下标记截断。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责由同一字段承载的非权限产品类别、生命周期阶段与进程结果。 -每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。Claude Code 会把结构化失败行放在最新参与失败的权限事实之前;当前产品版本中的 Codex 仍只生成权限诊断。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 +每个产品的权限事实都只包含有效模式、请求类别、无人值守决定与固定的安全原因。Claude Code 从 SDK 回调和 `permission_denied` 消息取得这些事实。Codex 从 app-server 请求、被拒绝的 item、`sandboxError` 与每次运行有界 stderr 尾部中的两个固定权限签名取得事实;原始 stderr 仍会转发给 Host,但绝不会复制进诊断。两个提供方都会把结构化失败行放在最新参与失败的权限事实之前。成功结果只返回严格的最终答案;本地取消仍以 `aborted` 结算且不附带权限说明;未发布的启动失败仍会拒绝 `start()`。提供方绝不会把任一诊断事实写入 assistant 输出、结构化输出或 `subagent/end.lastAssistantMessage`。 前台消费方依次呈现终止原因标题、可选诊断和任何部分 assistant 输出。一次性后台适配器会在失败 Job 的 detail 中,把同一诊断与终止原因一起保存。没有填写该字段的提供方保持原有行为。 @@ -63,7 +63,7 @@ Codex 默认使用 `never`,并接受 Codex 0.147.0 公开的三种原生非交 ## Verification -包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录前台与 Job 共享的诊断呈现,同时面向模型的产品工具 schema 不包含权限参数。 +包测试固定所有允许与拒绝的 Config 值、准确的 SDK 与 app-server 字段映射、危险确认、无人值守终态、诊断脱敏与 UTF-8 上限、成功结果不携带诊断、并发运行隔离、前台顺序、Job detail、stderr observer 释放和进程清理。真实 Claude Agent SDK/CLI fixture 证明其安全默认、受限拒绝、显式 bypass 与整棵进程树完全停稳。真实 Codex app-server fixture 证明线程级 `never` 覆盖环境中的 `on-request`、自动评审可以启动、危险绕过只在测试拥有的临时存储中写入、固定 stderr 签名产生安全诊断,而且 wrapper/native 进程树会退出。Loader 组装证明非默认模式可以在不启动任一产品的情况下发布;无密钥 ACP snapshot 则记录每个产品的失败诊断如何经过前台与 Job 呈现,同时面向模型的产品工具 schema 不包含权限参数。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml index b27044310b..d7652c90f3 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.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-18-product-subagent-failure-facts.md -2026-08-18-product-subagent-failure-facts.md: 4380e36d172f395692d2d84c97d45cba95701f9f -2026-08-18-product-subagent-failure-facts.zh.md: d601becdf14bd74ae871a66d4798ffe9c49b6490 +2026-08-18-product-subagent-failure-facts.md: 069c9114fd819fafa8872f58d3031acda28e64dd +2026-08-18-product-subagent-failure-facts.zh.md: 5d794cd12f1f7b8079739d4d119e1607471c22af diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md index 4380e36d17..069c9114fd 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.md @@ -19,10 +19,10 @@ Each product Provider owns the mapping from its pinned official error union, cur The structured line has this fixed order: ```text -Product subagent failure (product: ; stage: ; category: ; exit code: ; signal: ) +Product subagent failure (product: ; stage: ; category: ; HTTP status: ; exit code: ; signal: ) ``` -The Provider omits unavailable exit fields. Exit code and signal are independent facts and are each retained when observed. A contributing permission decision from the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) follows the structured line; the latest safe permission fact remains operation-local. The shared result boundary limits the complete text to 4096 UTF-8 bytes. +The Provider omits unavailable optional fields. Exit code and signal are independent facts and are each retained when observed. A contributing permission decision from the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) follows the structured line; the latest safe permission fact remains operation-local. The shared result boundary limits the complete text to 4096 UTF-8 bytes. Successful results and local cancellation expose no failure fact. Raw product errors, stderr, tool input, paths, environment values, credentials, and protocol payloads never enter the diagnostic. Startup and cleanup rejections use the same safe line in their Error message while retaining the original failure only on the internal cause chain and in Host logging. @@ -37,7 +37,20 @@ Agent SDK 0.3.220 defines four error subtypes: `error_during_execution`, `error_ | `process` | Managed CLI exits before the SDK supplies a terminal result | The run resolves as `error` with `process-exit` and the available exit code and signal | | `teardown` | Query close and managed process-tree release | `dispose()` rejects independently with fixed safe facts after cleanup still reaches its final exit wait | -The Codex Provider retains its existing result mapping: `contextWindowExceeded` is `max-tokens`, other turn failures remain `error`, and permission-related paths may carry their existing safe diagnostic. Other Codex error-info members are not represented as shared categories by this decision's current implementation. +### Codex facts + +Codex app-server 0.147.0 defines eleven string categories and five object variants. The Provider preserves `contextWindowExceeded`, `sessionBudgetExceeded`, `usageLimitExceeded`, `serverOverloaded`, `cyberPolicy`, `internalServerError`, `unauthorized`, `badRequest`, `threadRollbackFailed`, `sandboxError`, and `other`. It also preserves `httpConnectionFailed`, `responseStreamConnectionFailed`, `responseStreamDisconnected`, `responseTooManyFailedAttempts`, and `activeTurnNotSteerable`; the four connection/stream variants retain numeric `httpStatusCode`, while the active-turn variant does not expose `turnKind`. Unknown strings, objects with another variant set, malformed values, and unclassified exceptions use `unknown`. + +| Stage | Owned operation | Observable failure | +| --- | --- | --- | +| `initialize` | App-server spawn and initialize/initialized handshake | `start()` rejects with fixed safe facts and any process outcome already observed | +| `thread-start` | Ephemeral `thread/start` request and response validation | `start()` rejects with the thread stage and any available process outcome | +| `turn-start` | Published `turn/start` request, provisional ids, and early frames | The run resolves as `error` with a safe unknown fallback when no structured category exists | +| `turn` | Terminal notification, final-answer selection, and error-info mapping | The complete category and optional HTTP status reach the non-completed result | +| `process` | Managed app-server exits before another terminal path settles | The run resolves as `error` with `process-exit` and any available code and signal | +| `teardown` | Wire close and process-tree release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines | + +`contextWindowExceeded` remains `max-tokens`; every other known or unknown Codex category remains `error`, and `cyberPolicy` does not become `refusal`. ### Ownership and lifecycle @@ -51,7 +64,7 @@ The Codex Provider retains its existing result mapping: `contextWindowExceeded` ## Verification -Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. The real SDK/CLI fixture produces an actual `error_max_turns` result and an actual early process exit while proving whole-tree quiescence. The keyless ACP snapshot records the same failure diagnostic in foreground error output, the background completion notice, and `job_output`. +Claude Code package tests pin all four SDK subtypes, invalid success, missing result, unknown values and exceptions, all four stages, independent exit code and signal fields, permission-fact ordering, sanitization, successful-result and cancellation omission, concurrent-run isolation, and cleanup completion. Codex package tests pin all sixteen error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real SDK/CLI fixture produces an actual Claude `error_max_turns`; the real app-server fixture produces an actual Codex `internalServerError`; both fixtures cover process/protocol failure and whole-tree quiescence. The keyless ACP snapshot records each product's exact diagnostic in foreground error output, a background completion notice, and `job_output`. ## Alternatives considered @@ -67,7 +80,7 @@ Claude Code package tests pin all four SDK subtypes, invalid success, missing re ## Consequences -The parent can distinguish important Claude Code product limits, invalid terminal results, unknown query failures, and early process exits without receiving raw product text. Foreground and background scheduling preserve the same fact because both consume one `SubagentResult`. +The parent can distinguish important Claude Code limits and Codex budget, usage, service, policy, request, connection, stream, rollback, sandbox, and active-turn failures without receiving raw product text. Foreground and background scheduling preserve the same fact because both consume one `SubagentResult`. The diagnostic is display text rather than a new public protocol. Callers may present it but must not branch on its punctuation or product-private category names. A pinned product-version upgrade must update the Provider mapping and evidence when its official error union changes. diff --git a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md index d601becdf1..5d794cd12f 100644 --- a/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-product-subagent-failure-facts.zh.md @@ -19,10 +19,10 @@ Status: implemented 结构化行采用以下固定顺序: ```text -Product subagent failure (product: ; stage: ; category: ; exit code: ; signal: ) +Product subagent failure (product: ; stage: ; category: ; HTTP status: ; exit code: ; signal: ) ``` -提供方会省略不可用的退出字段。退出码与信号是相互独立的事实,只要已观测到就分别保留。来自[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)且参与失败的权限决定会跟在结构化行之后;最新的安全权限事实仍只属于当前操作。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。 +提供方会省略不可用的可选字段。退出码与信号是相互独立的事实,只要已观测到就分别保留。来自[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)且参与失败的权限决定会跟在结构化行之后;最新的安全权限事实仍只属于当前操作。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。 成功结果与本地取消都不公开失败事实。原始产品错误、stderr、工具输入、路径、环境值、凭证和协议 payload 绝不会进入诊断。启动与清理拒绝会在 Error 消息中使用同一安全行,而原始失败只保留在内部 cause 链与 Host 日志中。 @@ -37,7 +37,20 @@ Agent SDK 0.3.220 定义四种错误子类型:`error_during_execution`、`erro | `process` | SDK 提供终态结果之前受管 CLI 已退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码和信号 | | `teardown` | Query 关闭与受管进程树释放 | `dispose()` 独立拒绝并携带固定安全事实,同时清理仍会完成最终退出等待 | -Codex 提供方保留既有结果映射:`contextWindowExceeded` 是 `max-tokens`,其他轮次失败仍是 `error`,权限相关路径可以携带既有安全诊断。本决策的当前实现不会把其他 Codex error-info 成员表示为共享类别。 +### Codex 事实 + +Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant。提供方会保留 `contextWindowExceeded`、`sessionBudgetExceeded`、`usageLimitExceeded`、`serverOverloaded`、`cyberPolicy`、`internalServerError`、`unauthorized`、`badRequest`、`threadRollbackFailed`、`sandboxError` 和 `other`。它还会保留 `httpConnectionFailed`、`responseStreamConnectionFailed`、`responseStreamDisconnected`、`responseTooManyFailedAttempts` 与 `activeTurnNotSteerable`;四种连接/stream variant 会保留数值 `httpStatusCode`,而 active-turn variant 不公开 `turnKind`。未知字符串、同时含其他 variant 的对象、格式错误值与未分类异常统一使用 `unknown`。 + +| 阶段 | 归属操作 | 可观察失败 | +| --- | --- | --- | +| `initialize` | App-server spawn 与 initialize/initialized 握手 | `start()` 以固定安全事实和已经观测到的进程结果拒绝 | +| `thread-start` | 临时 `thread/start` 请求与响应校验 | `start()` 以线程阶段和可用进程结果拒绝 | +| `turn-start` | 已发布 `turn/start` 请求、暂定 id 与早到 frame | 没有结构化类别时,运行以 `error` 和安全 unknown 回退兑现 | +| `turn` | 终态通知、最终答案选择与 error-info 映射 | 完整类别与可选 HTTP status 进入非完成结果 | +| `process` | 受管 app-server 在另一终态路径结算前退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码与信号 | +| `teardown` | Wire 关闭与进程树释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 | + +`contextWindowExceeded` 仍是 `max-tokens`;其他所有已知或未知 Codex 类别仍是 `error`,`cyberPolicy` 不会变成 `refusal`。 ### 所有权与生命周期 @@ -51,7 +64,7 @@ Codex 提供方保留既有结果映射:`contextWindowExceeded` 是 `max-token ## Verification -Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。真实 SDK/CLI fixture 会产生真实的 `error_max_turns` 结果与真实的进程提前退出,并证明整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录同一份失败诊断。 +Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、未知值与异常、四个阶段、相互独立的退出码与信号字段、权限事实顺序、脱敏、成功结果与取消时省略诊断、并发运行隔离和清理完成。Codex 包测试固定全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 SDK/CLI fixture 会产生真实的 Claude `error_max_turns`,真实 app-server fixture 会产生真实的 Codex `internalServerError`;两个 fixture 都覆盖进程/协议失败与整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录两个产品各自的准确诊断。 ## Alternatives considered @@ -67,7 +80,7 @@ Claude Code 包测试固定四种 SDK 子类型、无效成功、缺失结果、 ## Consequences -父 agent 可以区分重要的 Claude Code 产品限制、无效终态结果、未知 query 失败和进程提前退出,而不会收到原始产品文本。前台与后台调度会保留同一事实,因为二者都消费同一个 `SubagentResult`。 +父 agent 可以区分重要的 Claude Code 限制,以及 Codex 预算、用量、服务、策略、请求、连接、stream、回滚、sandbox 和 active-turn 失败,而不会收到原始产品文本。前台与后台调度会保留同一事实,因为二者都消费同一个 `SubagentResult`。 诊断只是展示文本,不是新的公开协议。调用方可以呈现它,但不得根据其标点或产品私有类别名称进行分支。锁定产品版本升级并改变官方错误联合时,必须同步更新提供方映射与证据。 diff --git a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts index 56626802a4..314f1e9f74 100644 --- a/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts +++ b/examples/acp-agent/tests/fixtures/subagent-result-diagnostic.ts @@ -11,7 +11,28 @@ import { SessionId } from '@deepseek-ai/dsh-session' export const name = 'subagent-result-diagnostic' export const inject = ['subagents'] -const DIAGNOSTIC = 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)' +const RESULTS = [ + { + id: '00000000-0000-4000-8000-0000000000d1', + diagnostic: 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)', + output: [{ type: 'text' as const, text: 'partial assistant text' }], + }, + { + id: '00000000-0000-4000-8000-0000000000d2', + diagnostic: 'Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)', + output: [], + }, + { + id: '00000000-0000-4000-8000-0000000000d3', + diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)', + output: [{ type: 'text' as const, text: 'partial assistant text' }], + }, + { + id: '00000000-0000-4000-8000-0000000000d4', + diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)', + output: [], + }, +] as const class DiagnosticProvider implements SubagentProvider { readonly name = 'snapshot-diagnostic' @@ -24,19 +45,16 @@ class DiagnosticProvider implements SubagentProvider { throw new Error('snapshot diagnostic provider start aborted') } const index = this.starts++ - if (index > 1) { - throw new Error('snapshot diagnostic provider expected exactly two starts') + const fixture = RESULTS[index] + if (fixture === undefined) { + throw new Error('snapshot diagnostic provider expected exactly four starts') } return { - id: SessionId(index === 0 - ? '00000000-0000-4000-8000-0000000000d1' - : '00000000-0000-4000-8000-0000000000d2'), + id: SessionId(fixture.id), localAgent: undefined, result: Promise.resolve({ - output: index === 0 - ? [{ type: 'text' as const, text: 'partial assistant text' }] - : [], - diagnostic: DIAGNOSTIC, + output: [...fixture.output], + diagnostic: fixture.diagnostic, stopReason: 'error' as const, }), dispose: async () => {}, diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json index b75f1d9580..7d29e8fa61 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." } + { "op": "prompt", "text": "Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools." } ] } diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json index 6fbff83b8c..f81eb48573 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/replay.override.json @@ -3,8 +3,8 @@ "kind": "chunks", "chunks": [ { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}" } }, + { "type": "tool-call-delta", "index": 0, "id": "call_claude_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}" } }, { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, { "type": "finish", "reason": { "kind": "tool-calls" } } ] @@ -13,8 +13,8 @@ "kind": "chunks", "chunks": [ { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}" } }, + { "type": "tool-call-delta", "index": 0, "id": "call_claude_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}" } }, { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, { "type": "finish", "reason": { "kind": "tool-calls" } } ] @@ -23,8 +23,38 @@ "kind": "chunks", "chunks": [ { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_diagnostic_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_diagnostic_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } }, + { "type": "tool-call-delta", "index": 0, "id": "call_claude_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-1\",\"wait\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_claude_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-1\",\"wait\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_codex_foreground", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_foreground", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_codex_background", "name": "subagent_codex", "argumentsDelta": "{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_background", "name": "subagent_codex", "arguments": "{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_codex_output", "name": "job_output", "argumentsDelta": "{\"job_id\":\"subagent-2\",\"wait\":true}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_codex_output", "name": "job_output", "arguments": "{\"job_id\":\"subagent-2\",\"wait\":true}" } }, { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, { "type": "finish", "reason": { "kind": "tool-calls" } } ] diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl index b9a725162e..6d5f0ce330 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/session.jsonl @@ -1,51 +1,84 @@ {"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"eb9f20a0-9eac-480c-9904-71a1ffbb742a"}]}} {"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Use subagent_codex in the foreground exactly once; its result will fail with a diagnostic and partial output. Then use subagent_codex in the background exactly once and collect subagent-1 with job_output using wait true. After observing both failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"b8004c02-9892-40a7-b7a4-28f04879082c"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Observe four diagnostic failures with subagent_codex. First call it in the foreground for the Claude Code diagnostic, then in the background for the same Claude Code diagnostic and collect subagent-1 with job_output using wait true. Next call it in the foreground for the Codex diagnostic, then in the background for the same Codex diagnostic and collect subagent-2 with job_output using wait true. After all four failures, reply with exactly PARENT_OBSERVED_DIAGNOSTICS. Do not call any other tools."}],"source":{"kind":"user"},"role":"user","id":"eb9f20a0-9eac-480c-9904-71a1ffbb742a"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1785730415287,"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":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Use subagent_codex in the foreground","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Observe four diagnostic failures with","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} {"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}} -{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}}}} +{"type":"assistant/chunk","seq":10,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}}} +{"type":"assistant/chunk","seq":11,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}}}} {"type":"assistant/chunk","seq":12,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":13,"time":1783600630852,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"92e33995-2f02-4ad5-aec1-9df82cf4d583"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_diagnostic_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe foreground diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":false}"}} -{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_diagnostic_foreground"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"f63d21ab-ccdc-44f2-9a96-4c60b46e5318"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"assistant/message","seq":14,"time":1786781990608,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"3cc2d0b5-97a5-4685-af60-ed7f7db8f69a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786781990608,"data":{"turn":1,"step":1,"callId":"call_claude_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude foreground diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":false}"}} +{"type":"tool/result","seq":16,"time":1786781990613,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_claude_foreground"},"content":[{"type":"tool-result","toolCallId":"call_claude_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"8743817e-158e-45cb-88d9-a695b2653eca"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1786781990613,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1786781990618,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}} -{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":20,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":21,"time":1783600630926,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}}}} {"type":"assistant/chunk","seq":22,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":23,"time":1783600630944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"2fb444e2-7a52-4963-988e-b1ecbc3744d5"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_diagnostic_background","name":"subagent_codex","arguments":"{\"description\":\"Observe background diagnostic\",\"prompt\":\"Return the diagnostic failure.\",\"run_in_background\":true}"}} -{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Claude Co…"},"role":"user","id":"05f93dde-37e9-40e7-92d5-f8de526a8bee"}]}} -{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_diagnostic_background"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"3377f724-b4a7-4ce1-bed7-774f174917d6"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"assistant/message","seq":24,"time":1786781990622,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"b504312a-1dc5-46ce-87a5-12a5817511b9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":1786781990623,"data":{"turn":1,"step":2,"callId":"call_claude_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Claude background diagnostic\",\"prompt\":\"Return the Claude diagnostic failure.\",\"run_in_background\":true}"}} +{"type":"agent/inbox/spliced","seq":26,"time":1786781990627,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe Claude background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Claude background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cl…"},"role":"user","id":"0fdb9ddf-1657-4455-9941-e6a9daa8ae4a"}]}} +{"type":"tool/result","seq":27,"time":1786781990627,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_claude_background"},"content":[{"type":"tool-result","toolCallId":"call_claude_background","content":[{"type":"text","text":"started background subagent job subagent-1"}],"isError":false}],"role":"user","id":"fe60646b-0551-4703-aa03-c8cb5460d356"}},"sourceEventSeqs":[25],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1786781990627,"data":{"turn":1,"step":2}} {"type":"agent/inbox/spliced","seq":29,"time":1786781990627,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":30,"time":1786781990632,"data":{"turn":1,"step":3}} -{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Claude Co…"},"role":"user","id":"05f93dde-37e9-40e7-92d5-f8de526a8bee"},"surfaceOp":"append"} +{"type":"user/message","seq":31,"time":1786781990632,"data":{"content":[{"type":"text","text":"background job subagent-1 (subagent: Observe Claude background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Claude background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cl…"},"role":"user","id":"0fdb9ddf-1657-4455-9941-e6a9daa8ae4a"},"surfaceOp":"append"} {"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_diagnostic_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}} -{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"call_claude_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}}}} {"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f43f988b-bc08-4811-8671-8edc0613f0d0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} -{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_diagnostic_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} -{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_diagnostic_output"},"content":[{"type":"tool-result","toolCallId":"call_diagnostic_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]"}],"isError":false}],"role":"user","id":"10ac5635-c519-4f69-ab5a-cb0f930e9df0"}},"sourceEventSeqs":[38],"surfaceOp":"append"} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"c48a520a-74ed-42ee-9d93-ee59899975b0"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} +{"type":"tool/call","seq":38,"time":1786781990636,"data":{"turn":1,"step":3,"callId":"call_claude_output","name":"job_output","arguments":"{\"job_id\":\"subagent-1\",\"wait\":true}"}} +{"type":"tool/result","seq":39,"time":1786781990640,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"call_claude_output"},"content":[{"type":"tool-result","toolCallId":"call_claude_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]"}],"isError":false}],"role":"user","id":"45bc0705-7243-4173-a119-4c0655af8dc1"}},"sourceEventSeqs":[38],"surfaceOp":"append"} {"type":"step/end","seq":40,"time":1786781990640,"data":{"turn":1,"step":3}} {"type":"step/start","seq":41,"time":1786781990645,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}} -{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}} -{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} -{"type":"step/end","seq":48,"time":1786781990650,"data":{"turn":1,"step":4}} -{"type":"turn/end","seq":49,"time":1786781990650,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"assistant/chunk","seq":42,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":43,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_foreground","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}}} +{"type":"assistant/chunk","seq":44,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}}}} +{"type":"assistant/chunk","seq":45,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":46,"time":1786781990649,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":47,"time":1786781990649,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"89ab3728-fc3f-4825-97e5-383d46568d8c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} +{"type":"tool/call","seq":48,"time":1786994591759,"data":{"turn":1,"step":4,"callId":"call_codex_foreground","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex foreground diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":false}"}} +{"type":"tool/result","seq":49,"time":1786994591762,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"call_codex_foreground"},"content":[{"type":"tool-result","toolCallId":"call_codex_foreground","content":[{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)\nPartial output before the run ended:\npartial assistant text"}],"isError":true}],"role":"user","id":"0a8fd87c-eacb-457b-a5ad-29dd88f599aa"}},"sourceEventSeqs":[48],"surfaceOp":"append"} +{"type":"step/end","seq":50,"time":1786994591762,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":51,"time":1786994591767,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":52,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":53,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_background","name":"subagent_codex","argumentsDelta":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}}} +{"type":"assistant/chunk","seq":54,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}}}} +{"type":"assistant/chunk","seq":55,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":56,"time":1786994591771,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1786994591771,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"996da601-acb8-49c9-8dd7-e60a88a8f1a2"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1786994591772,"data":{"turn":1,"step":5,"callId":"call_codex_background","name":"subagent_codex","arguments":"{\"description\":\"Observe Codex background diagnostic\",\"prompt\":\"Return the Codex diagnostic failure.\",\"run_in_background\":true}"}} +{"type":"agent/inbox/spliced","seq":59,"time":1786994591775,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"background job subagent-2 (subagent: Observe Codex background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Codex background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cod…"},"role":"user","id":"5f1d4517-50d4-48ef-8acc-8f9361ecb185"}]}} +{"type":"tool/result","seq":60,"time":1786994591775,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"call_codex_background"},"content":[{"type":"tool-result","toolCallId":"call_codex_background","content":[{"type":"text","text":"started background subagent job subagent-2"}],"isError":false}],"role":"user","id":"a8ba8362-275b-4bca-8b88-d1ef84d325a3"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1786994591776,"data":{"turn":1,"step":5}} +{"type":"agent/inbox/spliced","seq":62,"time":1786994591776,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":63,"time":1786994591781,"data":{"turn":1,"step":6}} +{"type":"user/message","seq":64,"time":1786994591781,"data":{"content":[{"type":"text","text":"background job subagent-2 (subagent: Observe Codex background diagnostic) finished [status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]. Read its output with job_output."}],"source":{"kind":"plugin","plugin":"tool-jobs","form":"notice","summary":"subagent Observe Codex background diagnostic [status: failed, error; diagnostic: Product subagent failure (product: Cod…"},"role":"user","id":"5f1d4517-50d4-48ef-8acc-8f9361ecb185"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":65,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":66,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"call_codex_output","name":"job_output","argumentsDelta":"{\"job_id\":\"subagent-2\",\"wait\":true}"}}} +{"type":"assistant/chunk","seq":67,"time":1786994591788,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}}}} +{"type":"assistant/chunk","seq":68,"time":1786994591789,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":69,"time":1786994591789,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":70,"time":1786994591789,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"cfc1726c-5d9e-486a-aa0f-057219e16dfd"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[65,66,67,68,69],"surfaceOp":"append"} +{"type":"tool/call","seq":71,"time":1786994591789,"data":{"turn":1,"step":6,"callId":"call_codex_output","name":"job_output","arguments":"{\"job_id\":\"subagent-2\",\"wait\":true}"}} +{"type":"tool/result","seq":72,"time":1786994591797,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"call_codex_output"},"content":[{"type":"tool-result","toolCallId":"call_codex_output","content":[{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]"}],"isError":false}],"role":"user","id":"9671e5ee-f443-4548-8fd2-b0b76f00b629"}},"sourceEventSeqs":[71],"surfaceOp":"append"} +{"type":"step/end","seq":73,"time":1786994591797,"data":{"turn":1,"step":6}} +{"type":"step/start","seq":74,"time":1786994591802,"data":{"turn":1,"step":7}} +{"type":"assistant/chunk","seq":75,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":76,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PARENT_OBSERVED_DIAGNOSTICS"}}} +{"type":"assistant/chunk","seq":77,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}} +{"type":"assistant/chunk","seq":78,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":79,"time":1786994591806,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":80,"time":1786994591806,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"49b868e8-2608-47e0-aaf8-b308ffe8194d"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[75,76,77,78,79],"surfaceOp":"append"} +{"type":"step/end","seq":81,"time":1786994591806,"data":{"turn":1,"step":7}} +{"type":"turn/end","seq":82,"time":1786994591807,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 22f8e3c291..96790dec51 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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-codex/README.md -README.md: 645479474599eb4cb72c0bf73838a6341c98adb7 -README.zh.md: 1e9d21882b4c84312ea60eff3510bd2295d5334e +README.md: 6ec8287306fa0e9c27bed535a6b8b7311dad9f8b +README.zh.md: f5aadfa89202b5bdc18dec669d64b004848c4556 diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 6454794745..6ec8287306 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -6,13 +6,15 @@ This package registers the fixed `codex` subagent provider. Each accepted run st ## Start and ownership -`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. +`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It then spawns the fixed command through [`dsh-subprocess`](../../subprocess/subprocess/README.md), performs `initialize` → `initialized`, maps the Profile-selected mode into official `thread/start` approval/reviewer/sandbox fields beside `{ cwd, ephemeral: true }`, and publishes the run only after Codex returns a valid ephemeral thread. A failure or cancellation before publication closes the wire, terminates the managed process tree, waits for it to exit, and rejects `start()`. Non-cancellation rejections expose only the fixed `initialize` or `thread-start` stage plus an already observed process outcome; raw product and Host errors remain on internal cause chains. The published `run.result` starts exactly one turn. It accepts only notifications for that run's thread and turn, then waits for the authoritative `turn/completed` terminal notification. The latest `agentMessage` with `phase: "final_answer"` wins; when Codex emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback. Commentary never replaces either answer, and a successful turn with no nonblank answer settles as an error. For command and file approvals, the unattended provider selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It answers permission requests with an empty turn-scoped permission set, answers user-input requests with no answers, and declines MCP elicitation. A request with no legal unattended response, or any unknown server request, fails the run. The wire records only the effective mode, request category, decision, and fixed safe reason. It also recognizes declined command/file items and `sandboxError` terminals. Codex 0.147.0 writes some early `never` rejections and sandbox violations only to structured stderr, so the Provider pipes stderr, forwards it unchanged to the host, and matches two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. -Local cancellation wins the result race and maps to `aborted`. A failed turn whose `codexErrorInfo` is `contextWindowExceeded` maps to `max-tokens`; every other remote interrupted or failed turn maps to `error`, and the provider produces no `refusal`. A permission-related error may additionally carry the bounded, non-assistant `SubagentResult.diagnostic`; successful and locally cancelled runs omit it. `dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted`. For failed turns, the diagnostic preserves all eleven string and five object variants in the Codex 0.147.0 `codexErrorInfo` union; the four connection/stream variants retain a numeric `httpStatusCode` when supplied, while `activeTurnNotSteerable` does not expose `turnKind`. The diagnostic also names `turn-start`, `turn`, or `process`, independently includes available exit code and signal, and uses `unknown` for unrecognized or malformed values without copying raw fields. `contextWindowExceeded` remains `max-tokens`; every other remote interruption or failure remains `error`, and the provider produces no `refusal`. A contributing permission decision follows the structured failure line. Successful and locally cancelled runs omit both facts. + +`dispose()` is idempotent: it requests a best-effort `turn/interrupt` with both current ids when they are known, closes the JSON-RPC wire, ends stdin, invokes the shared process-tree termination escalation, waits for whole-tree exit, and detaches the stderr observer. Independent cleanup rejection uses the fixed `teardown` stage and any available process outcome. When startup and rollback both fail, the top-level aggregate message preserves both safe stage lines while the raw failures remain internal. ## Capabilities and context @@ -63,7 +65,7 @@ The standalone composition below shows the complete explicit capability. A Profi ## Product compatibility and evidence -The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. Real-product coverage proves that thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, safe diagnostics exclude raw commands and paths, and every wrapper/native process exits. +The production wire intentionally implements only the app-server methods required by this one-shot contract. Development evidence is pinned to `@openai/codex@0.147.0` / `codex-cli 0.147.0`; the npm package is a test-only dependency, and deployments still supply `codex` on `PATH`. Generated schema evidence pins the complete current error union and HTTP-status locations. Real-product coverage proves that thread-level `never` overrides an ambient `on-request`, automatic review starts through the official app-server, dangerous bypass writes only in suite-owned temporary storage, a real service failure becomes `internalServerError`, process/protocol failure remains safe and quiescent, raw commands and paths stay out of diagnostics, and every wrapper/native process exits. ## Model Experience @@ -85,7 +87,7 @@ Independent of the parent request cache. Reuse depends only on Codex's own provi #### What the model sees -Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session. +Through `dsh-tool-subagent`, a foreground call gives the parent the selected final Codex answer or an error containing the stop reason and optional safe diagnostic for a non-completed result. The diagnostic can distinguish the fixed error-info category, protocol stage, numeric HTTP status, and observed process outcome without copying product prose. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the same final answer or failed status detail through `job_output`, and let `job_kill` request cancellation. Codex commentary, reasoning, tool activity, raw stderr, workspace diffs, usage, product ids, commands, paths, and protocol payloads are not copied into the parent Session. #### Token effect @@ -98,7 +100,7 @@ Append-only: foreground adds one result after the reusable parent prefix, while ## Known Limitations and Deferred Work - **One fresh process, thread, and turn per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. -- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate. +- **Host-managed product installation and account state** — a missing or incompatible `codex`, configuration error, or authentication failure is surfaced with its lifecycle stage and the safe `unknown` fallback rather than a separate public taxonomy; the plugin provides no installer, login flow, or runtime version gate. - **Compatibility is pinned by development evidence** — upgrading from the verified 0.147.0 protocol baseline requires regenerating upstream schema evidence and rerunning handshake, answer-selection, approval, cancellation, keyless real-product, and credentialed DeepSeek nonce tests. - **No human approval path** — known unattended approval requests are denied and unknown server requests fail closed; the three Profile modes never create a DSH interaction channel or per-call allow policy. - **Assistant payload is final text only** — a failed run may additionally expose the separate safe diagnostic; reasoning, commentary, intermediate messages, tool traffic, usage, raw stderr, and workspace diffs remain outside the parent Session, while generic Job ids, notices, and status come from the shared job runtime. diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index 1e9d21882b..f5aadfa892 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -6,13 +6,15 @@ ## 启动与所有权 -`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。 +`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后,它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) spawn 固定命令,依次执行 `initialize` → `initialized`,把 Profile 选择的模式映射为官方 `thread/start` approval/reviewer/sandbox 字段并与 `{ cwd, ephemeral: true }` 一起发送,且仅在 Codex 返回有效的临时线程后才发布此次运行。若在发布前发生失败或取消,它会关闭通信链路、终止受管进程树并等待其退出,然后拒绝 `start()` 调用。非取消拒绝只公开固定的 `initialize` 或 `thread-start` 阶段及已经观测到的进程结果;原始产品与 Host 错误只保留在内部 cause 链中。 已发布的 `run.result` 恰好启动一个轮次。它只接受与此次运行的线程和轮次匹配的通知,随后等待权威的终止通知 `turn/completed`。以最后一条 `phase: "final_answer"` 的 `agentMessage` 为准;若 Codex 没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退。过程说明绝不会取代上述任一答案;成功完成的轮次若没有非空白答案,结果也会判为错误。 对于命令与文件审批,无人值守的提供方会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它对权限请求返回作用域限于当前轮次的空权限集,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败。wire 只记录有效模式、请求类别、决定与固定的安全原因,也会识别被拒绝的命令/文件 item 和 `sandboxError` 终态。Codex 0.147.0 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe stderr、原样转发给 Host,并在每次运行的有界尾缓冲中匹配两个固定签名;原始 stderr 不会进入诊断。 -本地取消会在结果竞态中胜出并映射为 `aborted`。失败轮次的 `codexErrorInfo` 若为 `contextWindowExceeded`,则映射为 `max-tokens`;其他任何远端中断或失败轮次都映射为 `error`,且该提供方不会产生 `refusal`。权限相关错误可以额外携带有界、非 assistant 的 `SubagentResult.diagnostic`;成功和本地取消不会附带它。`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`。对于失败轮次,诊断会保留 Codex 0.147.0 `codexErrorInfo` 联合中的全部十一种字符串与五种对象 variant;四种连接/stream variant 会在上游提供时保留数值 `httpStatusCode`,而 `activeTurnNotSteerable` 不公开 `turnKind`。诊断还会注明 `turn-start`、`turn` 或 `process`,分别包含可用的退出码与信号,并对无法识别或格式错误的值使用 `unknown`,且不复制原始字段。`contextWindowExceeded` 仍映射为 `max-tokens`;其他任何远端中断或失败仍映射为 `error`,且该提供方不会产生 `refusal`。参与失败的权限决定会跟在结构化失败行之后。成功与本地取消都不附带这两类事实。 + +`dispose()`(资源释放)具有幂等性:如果当前的两个标识符均已知,它会尽力请求 `turn/interrupt`,关闭 JSON-RPC 通信链路,结束标准输入,调用共享的进程树逐级终止机制,等待整棵进程树退出,并移除 stderr observer。独立清理拒绝使用固定的 `teardown` 阶段与可用进程结果。当启动与回滚同时失败时,顶层聚合消息会保留两条安全阶段说明,而原始失败仍只在内部可见。 ## 能力与上下文 @@ -63,7 +65,7 @@ ## 产品兼容性与证据 -生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。真实产品覆盖会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,安全诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。 +生产环境的协议层有意只实现这一单次执行约定所需的 app-server 方法。开发证据锁定在 `@openai/codex@0.147.0` / `codex-cli 0.147.0`;该 NPM 包仅作为测试依赖,部署环境仍需通过 `PATH` 提供 `codex`。生成的 schema 证据固定完整的当前错误联合与 HTTP status 所在位置。真实产品覆盖会证明线程级 `never` 覆盖环境中的 `on-request`,自动评审通过官方 app-server 启动,危险绕过只在测试拥有的临时存储中写入,真实服务失败成为 `internalServerError`,进程/协议失败保持安全并完全停稳,诊断不包含原始命令与路径,而且所有 wrapper/native 进程都会退出。 ## 模型体验 @@ -85,7 +87,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 #### 模型看到的内容 -通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。 +通过 `dsh-tool-subagent`,前台调用会让父级模型看到选定的 Codex 最终答案;若结果未完成,错误中会包含终止原因和可选的安全诊断。该诊断可以区分固定 error-info 类别、协议阶段、数值 HTTP status 和已观测的进程结果,而不复制产品正文。后台调用会先返回 Job id;随后通用作业控制面会送达完成通知,通过 `job_output` 公开同一最终答案或失败状态 detail,并允许 `job_kill` 请求取消。Codex 的过程说明、推理(reasoning)、工具活动、原始 stderr、工作区差异、用量信息、产品标识符、命令、路径和协议载荷均不会复制到父会话。 #### 对 token 的影响 @@ -98,7 +100,7 @@ Codex 子级会在一个全新的临时线程中,以单个轮次接收这些 ## 已知限制与后续工作 - **每次运行均新建一个进程、一个线程和一个轮次**:不支持续接、恢复、池化、进度流或产品会话持久化。 -- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败,都会呈现为启动错误或运行错误;本插件不提供安装程序、登录流程或运行时版本门禁。 +- **产品安装和账户状态由宿主管理**:`codex` 缺失或不兼容、配置错误或身份验证失败会公开其生命周期阶段与安全的 `unknown` 回退,而不会增加单独的公开分类体系;本插件不提供安装程序、登录流程或运行时版本门禁。 - **兼容性由开发证据锁定**:若要从已验证的 0.147.0 协议基线升级,必须重新生成上游 schema 证据,并重新运行握手、答案选择、审批、取消、无密钥真实产品以及带密钥的 DeepSeek 随机数测试。 - **没有人工审批路径**:已知的无人值守审批请求会被拒绝,未知服务器请求会以默认拒绝方式使运行失败;三种 Profile 模式都不会创建 DSH 交互通道或逐次调用 allow 策略。 - **assistant 载荷仅包含最终文本**:失败运行可以额外公开独立的安全诊断;推理、过程说明、中间消息、工具通信、用量信息、原始 stderr 和工作区差异不会进入父会话,通用 Job id、通知与状态来自共享作业运行时。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index a75dd4f049..0a0627666c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -19,8 +19,15 @@ import { type SubagentStartRequest, type SubagentStopReason, } from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' -import { CodexAppServerWire } from './wire.ts' +import type { + SubprocessHandle, + SubprocessOutcome, + SubprocessSpawnSpec, +} from '@deepseek-ai/dsh-subprocess' +import { + CodexAppServerWire, + type CodexWireFailureFacts, +} from './wire.ts' /** Default POSIX grace between subprocess termination tiers. */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 @@ -41,6 +48,53 @@ export const CODEX_PERMISSION_MODES = [ /** Safe default for unattended Codex runs. */ export const DEFAULT_CODEX_PERMISSION_MODE: CodexPermissionMode = 'never' +type CodexFailureStage = + | 'initialize' + | 'thread-start' + | CodexWireFailureFacts['stage'] + | 'process' + | 'teardown' + +interface CodexFailureFacts { + readonly stage: CodexFailureStage + readonly category: string + readonly httpStatus?: number | undefined + readonly outcome?: SubprocessOutcome | undefined +} + +function failureDiagnostic(facts: CodexFailureFacts): string { + const fields = [ + 'product: Codex', + `stage: ${facts.stage}`, + `category: ${facts.category}`, + ] + if (facts.httpStatus !== undefined) { + fields.push(`HTTP status: ${facts.httpStatus}`) + } + const exitCode = facts.outcome?.exitCode + if (exitCode !== null && exitCode !== undefined) { + fields.push(`exit code: ${exitCode}`) + } + const signal = facts.outcome?.signal + if (signal !== null && signal !== undefined) { + fields.push(`signal: ${signal}`) + } + return `Product subagent failure (${fields.join('; ')})` +} + +class CodexRunFailure extends Error { + constructor( + readonly facts: CodexFailureFacts, + cause?: unknown, + ) { + super( + `subagent-codex: ${failureDiagnostic(facts)}`, + cause === undefined ? undefined : { cause }, + ) + this.name = 'CodexRunFailure' + } +} + /** * Resolve the fixed app-server command for a platform. * @@ -111,19 +165,49 @@ export async function disposeCodexChild( wire: CodexAppServerWire, child: SubprocessHandle, ): Promise { - wire.close() - if (child.pid <= 0) { - await child.done.catch(() => {}) - return - } + const failures: Error[] = [] + let outcome: SubprocessOutcome | undefined try { - child.stdin?.end() - } catch { - // A concurrently closed stdin does not change tree ownership below. + wire.close() + } catch (error: unknown) { + failures.push(thrown(error)) } - child.terminate() - await child.waitForExit() - await child.done + + if (child.pid > 0) { + try { + child.stdin?.end() + } catch { + // A concurrently closed stdin does not change tree ownership below. + } + child.terminate() + try { + await child.waitForExit() + } catch (error: unknown) { + failures.push(thrown(error)) + } + try { + outcome = await child.done + } catch (error: unknown) { + failures.push(thrown(error)) + } + } else { + await child.done.catch(() => {}) + } + + const firstFailure = failures[0] + if (firstFailure === undefined) return + const facts = { + stage: 'teardown', + category: 'unknown', + outcome, + } as const + if (failures.length === 1) { + throw new CodexRunFailure(facts, firstFailure) + } + throw new AggregateError( + failures.map(failure => new CodexRunFailure(facts, failure)), + `subagent-codex: ${failureDiagnostic(facts)}`, + ) } /** @@ -141,13 +225,21 @@ export async function startCodexRun( throw new Error('subagent-codex: request was aborted before app-server startup') } - const child = spec.spawn({ - argv: codexAppServerArgv(), - cwd: spec.cwd, - stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, - graceMs: spec.disposeGraceMs, - env: spec.env, - }) + let child: SubprocessHandle + try { + child = spec.spawn({ + argv: codexAppServerArgv(), + cwd: spec.cwd, + stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' }, + graceMs: spec.disposeGraceMs, + env: spec.env, + }) + } catch (error: unknown) { + throw new CodexRunFailure({ + stage: 'initialize', + category: 'unknown', + }, thrown(error)) + } const wire = new CodexAppServerWire( child.stdout as NonNullable, @@ -181,11 +273,15 @@ export async function startCodexRun( } const processFailure: Promise = child.done.then( - outcome => Promise.reject(new Error( - 'subagent-codex: app-server exited before the run settled ' - + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`, - )), - (error: unknown) => Promise.reject(thrown(error)), + outcome => Promise.reject(new CodexRunFailure({ + stage: 'process', + category: 'process-exit', + outcome, + })), + (error: unknown) => Promise.reject(new CodexRunFailure({ + stage: 'process', + category: 'unknown', + }, thrown(error))), ) // A normal post-result dispose also closes the process. Keep that expected // late rejection observed after the result race has already settled. @@ -200,43 +296,81 @@ export async function startCodexRun( const onAbort = (): void => { requestCancel() } request.signal.addEventListener('abort', onAbort, { once: true }) + let startupStage: 'initialize' | 'thread-start' = 'initialize' try { wire.start() await Promise.race([wire.initialize(request.signal), processFailure]) + startupStage = 'thread-start' await Promise.race([wire.startThread(spec.cwd, request.signal), processFailure]) } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) + const cancelledBeforeCleanup = runAbort.signal.aborted + const failure = new CodexRunFailure({ + stage: startupStage, + category: 'unknown', + outcome: error instanceof CodexRunFailure + ? error.facts.outcome + : undefined, + }, thrown(error)) try { await disposeProcess() } catch (disposeError: unknown) { + const cleanupFailure = thrown(disposeError) throw new AggregateError( - [thrown(error), thrown(disposeError)], - 'subagent-codex: startup failed and app-server cleanup also failed', + [failure, cleanupFailure], + `${failure.message}; ${cleanupFailure.message}`, ) } - if (runAbort.signal.aborted) { + if (cancelledBeforeCleanup) { throw new Error('subagent-codex: request was aborted before run publication') } - throw thrown(error) + try { + request.signal.throwIfAborted() + } catch { + throw new Error('subagent-codex: request was aborted before run publication') + } + throw failure } const collectOutput = (): ContentBlock[] => wire.collectOutput() + let diagnostic: string | undefined + const recordFailureDiagnostic = (facts: CodexFailureFacts): string => { + const failure = failureDiagnostic(facts) + const permission = wire.collectDiagnostic() + diagnostic = permission === undefined + ? failure + : `${failure}\n${permission}` + return diagnostic + } const result: Promise = settleRunResult({ attempt: async () => { try { - return await Promise.race([ + const terminal = await Promise.race([ wire.runTurn(texts, runAbort.signal), processFailure, ]) + if (terminal.stopReason === 'completed') return terminal + const facts = wire.collectFailure() ?? { + stage: 'turn', + category: 'unknown', + } + return { ...terminal, diagnostic: recordFailureDiagnostic(facts) } } catch (error: unknown) { // Give stderr data already queued in Node one turn to reach the wire // before settlement snapshots the diagnostic; later OS data is best-effort. await new Promise((resolve) => { setImmediate(resolve) }) - throw error + const wireFacts = wire.collectFailure() + const facts = error instanceof CodexRunFailure + ? error.facts + : wireFacts ?? { stage: 'turn', category: 'unknown' } + recordFailureDiagnostic(facts) + throw error instanceof CodexRunFailure + ? error + : new CodexRunFailure(facts, thrown(error)) } }, collectOutput, - collectDiagnostic: () => wire.collectDiagnostic(), + collectDiagnostic: () => diagnostic, cancelled: () => runAbort.signal.aborted, onError: spec.onError, signal: request.signal, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index cd94695863..9cd6b4f049 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -15,6 +15,13 @@ import type { CodexPermissionMode } from './run.ts' type JsonObject = Record +/** Product facts owned by the Codex wire after publication. */ +export interface CodexWireFailureFacts { + readonly stage: 'turn-start' | 'turn' + readonly category: string + readonly httpStatus?: number | undefined +} + const THREAD_PERMISSION_PARAMS: Readonly> = { never: { approvalPolicy: 'never' }, 'approve-for-me': { @@ -86,22 +93,78 @@ function unattendedDecision(params: JsonObject): 'cancel' | 'decline' { throw new Error('subagent-codex: app-server offered no unattended approval decision') } -function isContextWindowExceeded(turn: JsonObject): boolean { - if (turn.status !== 'failed') return false - const error = turn.error - return error !== null - && typeof error === 'object' - && !Array.isArray(error) - && (error as JsonObject).codexErrorInfo === 'contextWindowExceeded' +function numericHttpStatus(value: unknown): number | undefined { + return typeof value === 'number' + && Number.isInteger(value) + && value >= 0 + && value <= 65_535 + ? value + : undefined } -function isSandboxFailure(turn: JsonObject): boolean { - if (turn.status !== 'failed') return false +function objectFailureInfo(value: JsonObject): { + readonly category: string + readonly httpStatus?: number | undefined +} { + const keys = Object.keys(value) + const category = keys[0] + if (keys.length !== 1 || category === undefined) { + return { category: 'unknown' } + } + const detail = value[category] + if (detail === null || typeof detail !== 'object' || Array.isArray(detail)) { + return { category: 'unknown' } + } + const fields = detail as JsonObject + switch (category) { + case 'httpConnectionFailed': + case 'responseStreamConnectionFailed': + case 'responseStreamDisconnected': + case 'responseTooManyFailedAttempts': + { + const httpStatus = numericHttpStatus(fields.httpStatusCode) + return httpStatus === undefined + ? { category } + : { category, httpStatus } + } + case 'activeTurnNotSteerable': + return { category } + default: + return { category: 'unknown' } + } +} + +function failureInfo(turn: JsonObject): { + readonly category: string + readonly httpStatus?: number | undefined +} { + if (turn.status !== 'failed') return { category: 'unknown' } const error = turn.error - return error !== null - && typeof error === 'object' - && !Array.isArray(error) - && (error as JsonObject).codexErrorInfo === 'sandboxError' + if (error === null || typeof error !== 'object' || Array.isArray(error)) { + return { category: 'unknown' } + } + const info = (error as JsonObject).codexErrorInfo + if (typeof info === 'string') { + switch (info) { + case 'contextWindowExceeded': + case 'sessionBudgetExceeded': + case 'usageLimitExceeded': + case 'serverOverloaded': + case 'cyberPolicy': + case 'internalServerError': + case 'unauthorized': + case 'badRequest': + case 'threadRollbackFailed': + case 'sandboxError': + case 'other': + return { category: info } + default: + return { category: 'unknown' } + } + } + return info !== null && typeof info === 'object' && !Array.isArray(info) + ? objectFailureInfo(info as JsonObject) + : { category: 'unknown' } } function unattendedDiagnostic( @@ -164,6 +227,7 @@ export class CodexAppServerWire { private lastFinalAnswer: string | undefined private lastUnphasedAnswer: string | undefined private diagnostic: string | undefined + private failure: CodexWireFailureFacts | undefined private diagnosticOrder = 0 private observationOrder = 0 private pendingDiagnostic: { @@ -262,22 +326,41 @@ export class CodexAppServerWire { }>() this.turnCompleted = completion const threadId = this.threadId as string - const response = object(await this.guarded(this.transport.request('turn/start', { - threadId, - input: texts.map(text => ({ type: 'text', text, text_elements: [] })), - }, signal), signal), 'turn/start response') - const turn = object(response.turn, 'turn/start turn') - this.commitTurnId(string(turn.id, 'turn/start turn id')) - - const completed = await this.guarded(completion.promise, signal) - const terminal = object(completed.params.turn, 'turn/completed turn') - const status = terminal.status - if (isContextWindowExceeded(terminal)) { - return { output: this.collectOutput(), stopReason: 'max-tokens' } + try { + const response = object(await this.guarded(this.transport.request('turn/start', { + threadId, + input: texts.map(text => ({ type: 'text', text, text_elements: [] })), + }, signal), signal), 'turn/start response') + const turn = object(response.turn, 'turn/start turn') + this.commitTurnId(string(turn.id, 'turn/start turn id')) + } catch (error: unknown) { + this.recordFailure({ stage: 'turn-start', category: 'unknown' }) + throw error } + + let completed: { + readonly params: JsonObject + readonly order: number + } + let terminal: JsonObject + try { + completed = await this.guarded(completion.promise, signal) + terminal = object(completed.params.turn, 'turn/completed turn') + } catch (error: unknown) { + this.recordFailure({ stage: 'turn', category: 'unknown' }) + throw error + } + const status = terminal.status if (status !== 'completed') { - const sandboxFailure = isSandboxFailure(terminal) - if (sandboxFailure) { + const parsed = failureInfo(terminal) + this.recordFailure(parsed.httpStatus === undefined + ? { stage: 'turn', category: parsed.category } + : { + stage: 'turn', + category: parsed.category, + httpStatus: parsed.httpStatus, + }) + if (parsed.category === 'sandboxError') { this.recordDiagnostic( 'sandbox execution', 'failed', @@ -285,15 +368,15 @@ export class CodexAppServerWire { completed.order, ) } - const detail = status === 'failed' - ? sandboxFailure - ? ': sandboxError' - : ': error' - : '' + if (parsed.category === 'contextWindowExceeded') { + return { output: this.collectOutput(), stopReason: 'max-tokens' } + } + const detail = status === 'failed' ? `: ${parsed.category}` : '' throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`) } const output = this.collectOutput() if (output.length === 0) { + this.recordFailure({ stage: 'turn', category: 'unknown' }) throw new Error('subagent-codex: Codex completed without a final answer') } return { output, stopReason: 'completed' } @@ -330,6 +413,14 @@ export class CodexAppServerWire { return this.diagnostic } + /** + * The structured failure fact observed for this published turn. + * @returns a fixed stage/category pair and optional HTTP status. + */ + collectFailure(): CodexWireFailureFacts | undefined { + return this.failure + } + /** * Observe product stderr while retaining only enough tail to recognize fixed * permission signatures. The raw text is never copied into the diagnostic. @@ -475,6 +566,10 @@ export class CodexAppServerWire { ) } + private recordFailure(facts: CodexWireFailureFacts): void { + this.failure = facts + } + private nextObservationOrder(): number { this.observationOrder += 1 return this.observationOrder diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index a060d1555d..6b5bdb6793 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -160,6 +160,49 @@ describe('real @openai/codex 0.147.0 product', () => { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('codex-cli 0.147.0') + const schemaRoot = mkdtempSync(join(tmpdir(), 'dsh-codex-schema-')) + roots.push(schemaRoot) + await execFileAsync(process.execPath, [ + codexEntry, + 'app-server', + 'generate-json-schema', + '--out', + schemaRoot, + ], { env: { ...process.env, ...harness.env } }) + const schema = JSON.parse(readFileSync( + join(schemaRoot, 'ServerNotification.json'), + 'utf8', + )) as { + definitions: { + CodexErrorInfo: { + oneOf: Array<{ + enum?: string[] + properties?: Record + }> + } + } + } + expect(schema.definitions.CodexErrorInfo.oneOf[0]?.enum).toEqual([ + 'contextWindowExceeded', + 'sessionBudgetExceeded', + 'usageLimitExceeded', + 'serverOverloaded', + 'cyberPolicy', + 'internalServerError', + 'unauthorized', + 'badRequest', + 'threadRollbackFailed', + 'sandboxError', + 'other', + ]) + expect(schema.definitions.CodexErrorInfo.oneOf.slice(1).map(variant => + Object.keys(variant.properties ?? {})[0])).toEqual([ + 'httpConnectionFailed', + 'responseStreamConnectionFailed', + 'responseStreamDisconnected', + 'responseTooManyFailedAttempts', + 'activeTurnNotSteerable', + ]) const run = await harness.ctx.subagents.start('codex', { prompt: [{ type: 'text', text: task }], @@ -223,11 +266,15 @@ describe('real @openai/codex 0.147.0 product', () => { const result = await run.result expect(result.output).toEqual([]) expect(result.stopReason).toBe('error') + const diagnosticLines = result.diagnostic?.split('\n') ?? [] + expect(diagnosticLines[0]).toBe( + 'Product subagent failure (product: Codex; stage: turn; category: other)', + ) expect([ 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', 'Codex unattended decision (mode: never; request: sandbox execution; decision: failed): Codex reported a sandbox failure', 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', - ]).toContain(result.diagnostic) + ]).toContain(diagnosticLines[1]) expect(result.diagnostic).not.toContain(command) expect(result.diagnostic).not.toContain(harness.workspace) await run.dispose() @@ -244,6 +291,49 @@ describe('real @openai/codex 0.147.0 product', () => { await expectQuiescent(harness.handles) }, 60_000) + it('reports a real service failure and an early app-server exit safely', async () => { + { + const { harness } = await realHarness([{ + kind: 'error', + status: 503, + message: 'SECRET_TOKEN in /private/secret.txt', + }]) + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Exercise the service failure path.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + const result = await run.result + expect(result).toMatchObject({ output: [], stopReason: 'error' }) + expect(result.diagnostic).toBe( + 'Product subagent failure (product: Codex; stage: turn; category: internalServerError)', + ) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + expect(result.diagnostic).not.toContain('/private/secret.txt') + await run.dispose() + await expectQuiescent(harness.handles) + } + { + const { harness, fixture } = await realHarness([{ kind: 'hold' }]) + const run = await harness.ctx.subagents.start('codex', { + prompt: [{ type: 'text', text: 'Exercise the process failure path.' }], + parent: harness.parent, + signal: new AbortController().signal, + }) + await fixture.requestStarted + expect(harness.handles).toHaveLength(1) + harness.handles[0]!.terminate() + await harness.handles[0]!.done + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: unknown)', + stopReason: 'error', + }) + await run.dispose() + await expectQuiescent(harness.handles) + } + }, 60_000) + it('executes an explicitly selected dangerous bypass write in the isolated workspace', async () => { const sideEffect = 'bypass-side-effect' const command = process.platform === 'win32' diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 497303237a..e3864d0a19 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -128,6 +128,7 @@ interface FakeChildOptions { readonly pid?: number readonly exitOnTerminate?: boolean readonly doneError?: Error + readonly waitForExitError?: Error } interface FakeChild { @@ -171,6 +172,9 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild { if (options.exitOnTerminate !== false) settle() }) const waitForExit = vi.fn(async (signal?: AbortSignal) => { + if (options.waitForExitError !== undefined) { + throw options.waitForExitError + } if (exited) return true if (signal === undefined) { await done.catch(() => {}) @@ -305,6 +309,37 @@ function turnCompleted( } } +function expectedFailureDiagnostic( + stage: 'initialize' | 'thread-start' | 'turn-start' | 'turn' | 'process' | 'teardown', + category: string, + options: { + readonly httpStatus?: number + readonly outcome?: Partial + } = {}, +): string { + const fields = [ + 'product: Codex', + `stage: ${stage}`, + `category: ${category}`, + ] + if (options.httpStatus !== undefined) { + fields.push(`HTTP status: ${options.httpStatus}`) + } + if ( + options.outcome?.exitCode !== null + && options.outcome?.exitCode !== undefined + ) { + fields.push(`exit code: ${options.outcome.exitCode}`) + } + if ( + options.outcome?.signal !== null + && options.outcome?.signal !== undefined + ) { + fields.push(`signal: ${options.outcome.signal}`) + } + return `Product subagent failure (${fields.join('; ')})` +} + describe('task admission and package contracts', () => { it('keeps the app-server command fixed on POSIX and Windows', () => { expect(codexAppServerArgv('linux')).toEqual([ @@ -558,23 +593,103 @@ describe('CodexAppServerWire', () => { wire.close() }) - it('maps only an explicit context-window failure to max-tokens', async () => { - const { child, wire } = await initializeWire() - const result = wire.runTurn(['task'], new AbortController().signal) - const turnStart = await child.peer.nextMethod('turn/start') - child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - child.peer.send( - agentMessage('partial answer', null), - turnCompleted('failed', 'turn-1', 'thread-1', { - message: 'too much context', - codexErrorInfo: 'contextWindowExceeded', - }), - ) - await expect(result).resolves.toEqual({ - output: [{ type: 'text', text: 'partial answer' }], - stopReason: 'max-tokens', - }) - wire.close() + it('maps the complete string error union without changing stop reasons', async () => { + const categories = [ + 'contextWindowExceeded', + 'sessionBudgetExceeded', + 'usageLimitExceeded', + 'serverOverloaded', + 'cyberPolicy', + 'internalServerError', + 'unauthorized', + 'badRequest', + 'threadRollbackFailed', + 'sandboxError', + 'other', + ] as const + for (const category of categories) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: category, + }), + ) + if (category === 'contextWindowExceeded') { + await expect(result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial answer' }], + stopReason: 'max-tokens', + }) + } else { + await expect(result).rejects.toThrow(`status failed: ${category}`) + } + expect(wire.collectFailure()).toEqual({ + stage: 'turn', + category, + }) + expect(JSON.stringify(wire.collectFailure())).not.toContain('SECRET_TOKEN') + expect(JSON.stringify(wire.collectFailure())).not.toContain('/private/secret.txt') + wire.close() + } + }) + + it('maps all object error variants and only numeric HTTP status', async () => { + const scenarios = [ + ['httpConnectionFailed', { httpStatusCode: 503 }, 503], + ['responseStreamConnectionFailed', { httpStatusCode: null }, undefined], + ['responseStreamDisconnected', {}, undefined], + ['responseTooManyFailedAttempts', { httpStatusCode: '503' }, undefined], + ['activeTurnNotSteerable', { turnKind: 'review' }, undefined], + ] as const + for (const [category, detail, httpStatus] of scenarios) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo: { [category]: detail }, + })) + await expect(result).rejects.toThrow(`status failed: ${category}`) + expect(wire.collectFailure()).toEqual({ + stage: 'turn', + category, + ...(httpStatus === undefined ? {} : { httpStatus }), + }) + expect(JSON.stringify(wire.collectFailure())).not.toContain('turnKind') + wire.close() + } + }) + + it('uses unknown for version-external or malformed error info', async () => { + for (const codexErrorInfo of [ + 'futureError', + { futureVariant: { message: 'SECRET_TOKEN' } }, + { + httpConnectionFailed: { httpStatusCode: 503 }, + otherVariant: {}, + }, + { httpConnectionFailed: null }, + ]) { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo, + })) + await expect(result).rejects.toThrow('status failed: unknown') + expect(wire.collectFailure()).toEqual({ + stage: 'turn', + category: 'unknown', + }) + wire.close() + } }) it('rejects invalid handshake, thread, and turn response shapes', async () => { @@ -604,6 +719,10 @@ describe('CodexAppServerWire', () => { const frame = await child.peer.nextMethod('turn/start') child.peer.respond(frame, { turn: { id: '' } }) await expect(pending).rejects.toThrow('turn/start turn id') + expect(wire.collectFailure()).toEqual({ + stage: 'turn-start', + category: 'unknown', + }) wire.close() } }) @@ -637,6 +756,10 @@ describe('CodexAppServerWire', () => { frames: [turnCompleted('failed', 'turn-1', 'thread-1', { message: 'no' })], message: 'status failed', }, + { + frames: [turnCompleted('failed', 'turn-1', 'thread-1', 'SECRET_TOKEN')], + message: 'status failed', + }, { frames: [turnCompleted('interrupted')], message: 'status interrupted', @@ -651,8 +774,13 @@ describe('CodexAppServerWire', () => { const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await nextTask() child.peer.send(...scenario.frames) await expect(result).rejects.toThrow(scenario.message) + expect(wire.collectFailure()).toEqual({ + stage: 'turn', + category: 'unknown', + }) wire.close() } }) @@ -1276,16 +1404,149 @@ describe('run lifecycle and quiescence', () => { await run.dispose() }) + it('reports turn-start failures and omits captured facts after success', async () => { + { + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: '' } }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('turn-start', 'unknown'), + stopReason: 'error', + }) + await run.dispose() + } + { + const { child, run, turnStart } = await publishRun() + child.peer.send({ + id: 'successful-approval', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + }, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + await child.peer.nextResponse('successful-approval') + child.peer.send( + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + await run.dispose() + } + }) + + it('preserves representative terminal categories, HTTP status, and mapping', async () => { + const scenarios = [ + ['contextWindowExceeded', 'max-tokens', undefined], + ['sessionBudgetExceeded', 'error', undefined], + [{ httpConnectionFailed: { httpStatusCode: 503 } }, 'error', 503], + [{ activeTurnNotSteerable: { turnKind: 'review' } }, 'error', undefined], + ['futureError', 'error', undefined], + ] as const + for (const [codexErrorInfo, stopReason, httpStatus] of scenarios) { + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'SECRET_TOKEN in /private/secret.txt', + codexErrorInfo, + }), + ) + const category = typeof codexErrorInfo === 'string' + && codexErrorInfo !== 'futureError' + ? codexErrorInfo + : typeof codexErrorInfo === 'object' + ? Object.keys(codexErrorInfo)[0]! + : 'unknown' + const result = await run.result + expect(result).toEqual({ + output: [{ type: 'text', text: 'partial answer' }], + diagnostic: expectedFailureDiagnostic('turn', category, { + ...(httpStatus === undefined ? {} : { httpStatus }), + }), + stopReason, + }) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + expect(result.diagnostic).not.toContain('/private/secret.txt') + expect(result.diagnostic).not.toContain('turnKind') + await run.dispose() + } + }) + + it('uses safe unknown fallbacks when the wire supplies no failure fact', async () => { + { + const collectFailure = vi.spyOn( + CodexAppServerWire.prototype, + 'collectFailure', + ).mockReturnValue(undefined) + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + codexErrorInfo: 'contextWindowExceeded', + })) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('turn', 'unknown'), + stopReason: 'max-tokens', + }) + collectFailure.mockRestore() + await run.dispose() + } + { + const runTurn = vi.spyOn(CodexAppServerWire.prototype, 'runTurn') + .mockRejectedValueOnce(new Error('SECRET_TOKEN wire failure')) + const child = fakeChild() + const starting = startCodexRun(request(), runSpec(child)) + const initialize = await child.peer.nextMethod('initialize') + child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) + await child.peer.nextMethod('initialized') + const threadStart = await child.peer.nextMethod('thread/start') + child.peer.respond(threadStart, { + thread: { id: 'thread-1', ephemeral: true }, + }) + const run = await starting + const result = await run.result + expect(result).toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('turn', 'unknown'), + stopReason: 'error', + }) + expect(result.diagnostic).not.toContain('SECRET_TOKEN') + runTurn.mockRestore() + await run.dispose() + } + }) + it('flattens child exit and protocol failures after publication', async () => { const errors: string[] = [] - { + const outcomes: SubprocessOutcome[] = [ + { exitCode: 9, signal: null }, + { exitCode: null, signal: 'SIGABRT' }, + { exitCode: 9, signal: 'SIGABRT' }, + { exitCode: null, signal: null }, + ] + for (const outcome of outcomes) { const child = fakeChild({ exitOnTerminate: false }) const { run } = await publishRun(child, undefined, { onError: (error) => { errors.push(error.message) }, }) - child.settle({ exitCode: 9, signal: null }) - await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) - expect(errors.at(-1)).toContain('code 9') + child.settle(outcome) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('process', 'process-exit', { + outcome, + }), + stopReason: 'error', + }) + expect(errors.at(-1)).toBe( + `subagent-codex: ${expectedFailureDiagnostic('process', 'process-exit', { outcome })}`, + ) await run.dispose().catch(() => {}) } { @@ -1295,7 +1556,11 @@ describe('run lifecycle and quiescence', () => { }) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) child.fromChild.end() - await expect(run.result).resolves.toEqual({ output: [], stopReason: 'error' }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('turn', 'unknown'), + stopReason: 'error', + }) await run.dispose() } { @@ -1336,7 +1601,7 @@ describe('run lifecycle and quiescence', () => { })) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`, stopReason: 'error', }) await run.dispose() @@ -1356,7 +1621,7 @@ describe('run lifecycle and quiescence', () => { }) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, stopReason: 'error', }) await run.dispose() @@ -1378,7 +1643,7 @@ describe('run lifecycle and quiescence', () => { })) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, stopReason: 'error', }) expect(Buffer.concat(hostStderrWrite.chunks).toString()).toContain('SECRET_TOKEN') @@ -1401,7 +1666,7 @@ describe('run lifecycle and quiescence', () => { })) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'badRequest')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, stopReason: 'error', }) await run.dispose() @@ -1424,13 +1689,69 @@ describe('run lifecycle and quiescence', () => { )).rejects.toThrow('aborted before app-server startup') expect(spawn).not.toHaveBeenCalled() + const spawnFailure = startCodexRun(request(), { + cwd: process.cwd(), + permissionMode: DEFAULT_CODEX_PERMISSION_MODE, + env: {}, + disposeGraceMs: 10, + spawn: () => { throw new Error('SECRET_TOKEN spawn failure') }, + }) + await expect(spawnFailure) + .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown')) + await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN') + const child = fakeChild() const starting = startCodexRun(request(), runSpec(child)) const initialize = await child.peer.nextMethod('initialize') child.peer.respond(initialize, null) - await expect(starting).rejects.toThrow('invalid initialize response') + await expect(starting) + .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown')) + await expect(starting).rejects.not.toThrow('invalid initialize response') expect(child.terminate).toHaveBeenCalledTimes(1) + const cleanupRaceAbort = new AbortController() + const cleanupRaceChild = fakeChild({ exitOnTerminate: false }) + const cleanupRace = startCodexRun( + request(undefined, cleanupRaceAbort.signal), + runSpec(cleanupRaceChild), + ) + const cleanupRaceInitialize = await cleanupRaceChild.peer.nextMethod('initialize') + cleanupRaceChild.peer.respond(cleanupRaceInitialize, null) + await nextTask() + cleanupRaceAbort.abort(new Error('cancelled during cleanup')) + cleanupRaceChild.settle() + await expect(cleanupRace) + .rejects.toThrow('aborted before run publication') + + const threadChild = fakeChild() + const threadStarting = startCodexRun(request(), runSpec(threadChild)) + const threadInitialize = await threadChild.peer.nextMethod('initialize') + threadChild.peer.respond(threadInitialize, { userAgent: 'codex-cli 0.147.0' }) + await threadChild.peer.nextMethod('initialized') + const invalidThread = await threadChild.peer.nextMethod('thread/start') + threadChild.peer.respond(invalidThread, { thread: { id: '', ephemeral: true } }) + await expect(threadStarting) + .rejects.toThrow(expectedFailureDiagnostic('thread-start', 'unknown')) + await expect(threadStarting).rejects.not.toThrow('thread/start thread id') + + const exitedThreadChild = fakeChild({ exitOnTerminate: false }) + const exitedThreadStarting = startCodexRun( + request(), + runSpec(exitedThreadChild), + ) + const exitedThreadInitialize = await exitedThreadChild.peer.nextMethod('initialize') + exitedThreadChild.peer.respond(exitedThreadInitialize, { + userAgent: 'codex-cli 0.147.0', + }) + await exitedThreadChild.peer.nextMethod('initialized') + await exitedThreadChild.peer.nextMethod('thread/start') + exitedThreadChild.settle({ exitCode: 17, signal: 'SIGABRT' }) + await expect(exitedThreadStarting).rejects.toThrow(expectedFailureDiagnostic( + 'thread-start', + 'unknown', + { outcome: { exitCode: 17, signal: 'SIGABRT' } }, + )) + const stderrChild = fakeChild() const stderrStarting = startCodexRun(request(), runSpec(stderrChild)) const stderrInitialize = await stderrChild.peer.nextMethod('initialize') @@ -1486,8 +1807,12 @@ describe('run lifecycle and quiescence', () => { throw new Error('expected startup and rollback failures') } expect(error.errors).toEqual([ - expect.objectContaining({ message: 'spawn observer failed' }), - expect.objectContaining({ message: 'spawn observer failed' }), + expect.objectContaining({ + message: `subagent-codex: ${expectedFailureDiagnostic('initialize', 'unknown')}`, + }), + expect.objectContaining({ + message: `subagent-codex: ${expectedFailureDiagnostic('teardown', 'unknown')}`, + }), ]) expect(child.terminate).toHaveBeenCalledTimes(1) }) @@ -1564,12 +1889,12 @@ describe('run lifecycle and quiescence', () => { })) await expect(first.run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: never; request: command approval; decision: cancelled): the provider does not grant interactive approval`, stopReason: 'error', }) await expect(second.run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input', + diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: dangerously-bypass-approvals-and-sandbox; request: MCP elicitation; decision: declined): the provider does not collect interactive MCP input`, stopReason: 'error', }) await Promise.all([first.run.dispose(), second.run.dispose()]) @@ -1628,7 +1953,7 @@ describe('run lifecycle and quiescence', () => { })) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Codex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval', + diagnostic: `${expectedFailureDiagnostic('turn', 'other')}\nCodex unattended decision (mode: approve-for-me; request: command approval; decision: cancelled): the provider does not grant interactive approval`, stopReason: 'error', }) expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ @@ -1638,7 +1963,7 @@ describe('run lifecycle and quiescence', () => { cwd: process.cwd(), })) expect(warnings).toEqual([ - expect.stringContaining('subagent-codex: child run failed (error): subagent-codex: Codex turn ended with status failed: error'), + expect.stringContaining(`subagent-codex: child run failed (error): subagent-codex: ${expectedFailureDiagnostic('turn', 'other')}`), ]) expect(warnings.join('\n')).not.toContain('SECRET_TOKEN') expect(warnings.join('\n')).not.toContain('/private/secret.txt') @@ -1702,7 +2027,7 @@ describe('disposeCodexChild', () => { }) const wire = defaultWire(child) await expect(disposeCodexChild(wire, child.handle)) - .rejects.toThrow('close observer failed') + .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) } { const child = fakeChild() @@ -1711,4 +2036,23 @@ describe('disposeCodexChild', () => { await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() } }) + + it('aggregates wire-close and tree-wait failures with safe teardown facts', async () => { + const child = fakeChild({ + waitForExitError: new Error('SECRET_TOKEN wait failure'), + }) + const wire = defaultWire(child) + vi.spyOn(wire, 'close').mockImplementation(() => { + throw new Error('/private/secret.txt close failure') + }) + const disposal = disposeCodexChild(wire, child.handle) + await expect(disposal).rejects.toBeInstanceOf(AggregateError) + await expect(disposal).rejects.toThrow(expectedFailureDiagnostic( + 'teardown', + 'unknown', + { outcome: { exitCode: 0, signal: null } }, + )) + await expect(disposal).rejects.not.toThrow('SECRET_TOKEN') + await expect(disposal).rejects.not.toThrow('/private/secret.txt') + }) }) From efd2999aec2d9b963a074c9bcf1b08a561eee347 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 04:03:56 +0800 Subject: [PATCH 061/137] test(subagent): correlate Claude process failure facts --- .../tests/real-product.spec.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 344515d4cf..79d0a5a269 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -242,6 +242,14 @@ function expectedFailure( return `Product subagent failure (${fields.join('; ')})` } +function expectedObservedFailure(outcome: SubprocessOutcome): string { + return observedSdkMessages.some(message => + message.type === 'result' + && message.subtype === 'error_during_execution') + ? expectedFailure('query-run', 'error_during_execution', outcome) + : expectedFailure('process', 'process-exit', outcome) +} + function startRequest( harness: RealHarness, prompt: string, @@ -363,10 +371,7 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 const result = await run.result expect(result.output).toEqual([]) expect(result.stopReason).toBe('error') - expect([ - expectedFailure('process', 'process-exit', outcome), - expectedFailure('query-run', 'error_during_execution', outcome), - ]).toContain(result.diagnostic) + expect(result.diagnostic).toBe(expectedObservedFailure(outcome)) await run.dispose() expect(fixture.requests).toHaveLength(1) expect(fixture.requests[0]!.headers['x-api-key']).toBe(fakeKey) @@ -398,10 +403,7 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 expect(result.output).toEqual([]) expect(result.stopReason).toBe('error') const diagnosticLines = result.diagnostic?.split('\n') ?? [] - expect([ - expectedFailure('process', 'process-exit', outcome), - expectedFailure('query-run', 'error_during_execution', outcome), - ]).toContain(diagnosticLines[0]) + expect(diagnosticLines[0]).toBe(expectedObservedFailure(outcome)) expect(diagnosticLines[1]).toBe( 'Claude Code unattended decision (mode: dontAsk; request: tool permission; decision: denied): Claude Code denied the request before an interactive prompt', ) From f0b2d7c762f5975169dfcf93fe39d27a76c02505 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 05:01:37 +0800 Subject: [PATCH 062/137] refactor(subagent): avoid duplicate process diagnostics --- packages/subagent/subagent-codex/src/run.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 0a0627666c..6265587fab 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -71,13 +71,12 @@ function failureDiagnostic(facts: CodexFailureFacts): string { if (facts.httpStatus !== undefined) { fields.push(`HTTP status: ${facts.httpStatus}`) } - const exitCode = facts.outcome?.exitCode - if (exitCode !== null && exitCode !== undefined) { - fields.push(`exit code: ${exitCode}`) - } - const signal = facts.outcome?.signal - if (signal !== null && signal !== undefined) { - fields.push(`signal: ${signal}`) + const processFields = [ + ['exit code', facts.outcome?.exitCode], + ['signal', facts.outcome?.signal], + ] as const + for (const [label, value] of processFields) { + if (value !== null && value !== undefined) fields.push(`${label}: ${value}`) } return `Product subagent failure (${fields.join('; ')})` } From 4c6ff93535bf15e28569de908e1ab27e21dfc64b Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 05:17:47 +0800 Subject: [PATCH 063/137] fix(subagent): preserve Claude failure provenance --- .../subagent-claude-code/README.i18n.yaml | 4 +- .../subagent/subagent-claude-code/README.md | 2 +- .../subagent-claude-code/README.zh.md | 2 +- .../subagent-claude-code/src/index.ts | 7 +- .../subagent/subagent-claude-code/src/run.ts | 67 ++++++++++++----- .../tests/subagent-claude-code.spec.ts | 72 +++++++++++++++++-- 6 files changed, 125 insertions(+), 29 deletions(-) diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index c2fe8df4db..c3a2b9b6be 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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-claude-code/README.md -README.md: 21beb0a0534e601f9dd26d36f53d1fb5f09b4e07 -README.zh.md: c83999ee7b2d38e9c4ee57ba74df25217998bffd +README.md: 4a289236aca01b01fe8ff48c09776052729f6e52 +README.zh.md: 482e125136f9f9b7749112eb2130c52e383bb0b3 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 21beb0a053..4a289236ac 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -10,7 +10,7 @@ This package registers the fixed `claude-code` subagent provider. Each accepted The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every failure still maps to `error`: the four error subtypes in Agent SDK 0.3.220 retain their exact category, an error-marked or blank success becomes `invalid-success`, a missing result becomes `missing-result`, an unclassified query failure becomes `unknown`, and an early CLI exit becomes `process-exit`. The diagnostic also names the current `query-start`, `query-run`, `process`, or `teardown` stage and independently includes an observed exit code and signal. The provider produces neither `max-tokens` nor `refusal`. -Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains only on the internal cause chain. Result failure and independent teardown failure remain separate. +Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains on the internal cause chain and in the Provider's Host log. Result failure and independent teardown failure remain separate. ## Native settings and interaction diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index c83999ee7b..482e125136 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -10,7 +10,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"`、`is_error: false` 且 `result` 非空白,之后迭代器还须正常结束。所有失败仍映射为 `error`:Agent SDK 0.3.220 的四种错误子类型保留准确类别;标记为错误或内容空白的成功消息成为 `invalid-success`;缺失结果成为 `missing-result`;未分类的 query 失败成为 `unknown`;CLI 提前退出成为 `process-exit`。诊断还会注明当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,并分别保留已观测到的退出码与信号。该提供方不会产生 `max-tokens` 或 `refusal`。 -本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链上。结果失败与独立的清理失败仍彼此分离。 +本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链与提供方的 Host 日志中。结果失败与独立的清理失败仍彼此分离。 ## 原生设置与交互 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 1359b1d642..eb7779c4c3 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -98,7 +98,12 @@ class ClaudeCodeProvider implements SubagentProvider { 'subagent-claude-code: request was aborted before SDK startup', ) } - throw claudeCodeStartupFailure(error) + const failure = claudeCodeStartupFailure(error) + this.ctx.logger.warn( + 'subagent-claude-code: child start failed: %o', + failure, + ) + throw failure } const spec: ClaudeCodeRunSpec = { cwd, diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 5ea41d9bac..d10fd647f8 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -160,7 +160,7 @@ export interface ClaudeCodeRunSpec { readonly disposeGraceMs: number /** Shared subprocess service spawn operation. */ readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle - /** Diagnostic sink for a post-publication error flattened into a result. */ + /** Host diagnostic sink for a product failure kept outside model-visible text. */ readonly onError?: (error: Error, stopReason: SubagentStopReason) => void } @@ -224,11 +224,13 @@ export function successfulResult(message: SDKResultMessage): string { * iterator completion. * @param query - published official SDK query. * @param onPermissionDenied - records a safe fact when the SDK reports native denial. + * @param onResult - records that the SDK supplied a terminal result message. * @returns the completed shared result. */ export async function consumeClaudeQuery( query: AsyncIterable, onPermissionDenied?: () => void, + onResult?: () => void, ): Promise { let answer: string | undefined for await (const message of query) { @@ -237,6 +239,7 @@ export async function consumeClaudeQuery( continue } if (message.type !== 'result') continue + onResult?.() answer = successfulResult(message) } if (answer === undefined) { @@ -394,6 +397,13 @@ export async function startClaudeCodeRun( } const onAbort = (): void => { requestCancel() } request.signal.addEventListener('abort', onAbort, { once: true }) + const reportFailure = (error: Error): void => { + try { + spec.onError?.(error, 'error') + } catch { + // Host diagnostic logging cannot replace the product failure. + } + } let child: SubprocessHandle | undefined let query: Query | undefined @@ -435,6 +445,7 @@ export async function startClaudeCodeRun( } } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) + // Let child.done publish a concurrently observed exit before classification. await Promise.resolve() const startupOutcome = managedProcess?.outcome const startupFacts = { @@ -453,10 +464,12 @@ export async function startClaudeCodeRun( } catch (disposeError: unknown) { const failure = startupFailure() const cleanupFailure = thrown(disposeError) - throw new AggregateError( + const aggregate = new AggregateError( [failure, cleanupFailure], `${failure.message}; ${cleanupFailure.message}`, ) + reportFailure(aggregate) + throw aggregate } } else if (query !== undefined) { try { @@ -467,10 +480,12 @@ export async function startClaudeCodeRun( stage: 'teardown', category: 'unknown', }, thrown(disposeError)) - throw new AggregateError( + const aggregate = new AggregateError( [failure, cleanupFailure], `${failure.message}; ${cleanupFailure.message}`, ) + reportFailure(aggregate) + throw aggregate } } try { @@ -478,11 +493,14 @@ export async function startClaudeCodeRun( } catch { throw new Error('subagent-claude-code: request was aborted before SDK startup') } - throw startupFailure() + const failure = startupFailure() + reportFailure(failure) + throw failure } const publishedQuery = query const publishedChild = child + let receivedResult = false const result = settleRunResult({ attempt: async () => { try { @@ -493,19 +511,29 @@ export async function startClaudeCodeRun( 'denied', 'Claude Code denied the request before an interactive prompt', )) + }, () => { + receivedResult = true }) } catch (error: unknown) { const processOutcome = managedProcess?.outcome - const facts = error instanceof ClaudeCodeFailure - ? { ...error.facts, outcome: processOutcome } - : processOutcome === undefined - ? { stage: 'query-run', category: 'unknown' } as const - : { - stage: 'process', - category: 'process-exit', - outcome: processOutcome, - } as const + let facts: ClaudeCodeFailureFacts + if (error instanceof ClaudeCodeFailure) { + facts = { ...error.facts, outcome: processOutcome } + } else if (processOutcome !== undefined && !receivedResult) { + facts = { + stage: 'process', + category: 'process-exit', + outcome: processOutcome, + } + } else { + facts = { + stage: 'query-run', + category: 'unknown', + outcome: processOutcome, + } + } prependFailureDiagnostic(facts) + // Keep the SDK category and cause; the diagnostic adds later process facts. throw error instanceof ClaudeCodeFailure ? error : new ClaudeCodeFailure(facts, thrown(error)) @@ -525,9 +553,14 @@ export async function startClaudeCodeRun( signal: request.signal, onAbort, requestCancel, - teardown: () => disposeClaudeCodeChild( - publishedQuery, - publishedChild, - ), + teardown: async () => { + try { + await disposeClaudeCodeChild(publishedQuery, publishedChild) + } catch (error: unknown) { + const failure = thrown(error) + reportFailure(failure) + throw failure + } + }, }) } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 786f19d37c..b853dc9004 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -415,8 +415,17 @@ describe('task admission and package contracts', () => { expect(queryMock).not.toHaveBeenCalled() resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) - await expect(ctx.subagents.start('claude-code', request())) + const missingExecutable = ctx.subagents.start('claude-code', request()) + await expect(missingExecutable) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) + await expect(missingExecutable).rejects.not.toThrow('claude missing from PATH') + expect(warn).toHaveBeenCalledWith( + 'subagent-claude-code: child start failed: %o', + expect.any(Error), + ) + expect(warn.mock.calls[0]?.[1]).toMatchObject({ + cause: expect.objectContaining({ message: 'claude missing from PATH' }), + }) expect(queryMock).not.toHaveBeenCalled() const resolutionAbort = new AbortController() @@ -914,14 +923,29 @@ describe('run publication, cancellation, and settlement', () => { }) it('fails closed when iteration rejects after a result', async () => { - const fixture = fakeRun( - [success('partial final')], - new Error('iterator boom'), - ) - const run = await startClaudeCodeRun(request(), fixture.spec) + const child = fakeChild() + const outcome = { exitCode: 31, signal: null } as const + async function* stream(): AsyncGenerator { + yield success('partial final') + child.settle(outcome) + await Promise.resolve() + throw new Error('iterator boom') + } + queryMock.mockImplementation(({ options }) => { + options.spawnClaudeCodeProcess!(sdkSpawnOptions()) + return Object.assign(stream(), { close: vi.fn() }) as unknown as Query + }) + const run = await startClaudeCodeRun(request(), { + cwd: '/workspace', + executable: '/native/claude', + permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE, + env: {}, + disposeGraceMs: 5, + spawn: () => child.handle, + }) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: expectedFailureDiagnostic('query-run', 'unknown'), + diagnostic: expectedFailureDiagnostic('query-run', 'unknown', outcome), stopReason: 'error', }) await run.dispose() @@ -1141,14 +1165,25 @@ describe('run publication, cancellation, and settlement', () => { queryMock.mockImplementationOnce(() => { throw new Error('query failed before resource creation') }) + const queryFailureOnError = vi.fn() const queryFailure = startClaudeCodeRun(request(), { ...unused.spec, + onError: queryFailureOnError, }) await expect(queryFailure) .rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown')) await expect(queryFailure).rejects.not.toThrow( 'query failed before resource creation', ) + expect(queryFailureOnError).toHaveBeenCalledWith( + expect.any(Error), + 'error', + ) + expect(queryFailureOnError.mock.calls[0]?.[0]).toMatchObject({ + cause: expect.objectContaining({ + message: 'query failed before resource creation', + }), + }) const spawned = fakeChild() const spawnSpecs: SubprocessSpawnSpec[] = [] @@ -1225,6 +1260,29 @@ describe('query and process disposal', () => { }) }) + it('reports a published teardown failure to the Host diagnostic sink', async () => { + const fixture = fakeRun([success('exact answer')]) + const onError = vi.fn() + const run = await startClaudeCodeRun(request(), { + ...fixture.spec, + onError, + }) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + fixture.close.mockImplementationOnce(() => { + throw new Error('SECRET_TOKEN close failure') + }) + await expect(run.dispose()).rejects.toThrow( + expectedFailureDiagnostic('teardown', 'unknown', { + exitCode: 0, + signal: null, + }), + ) + expect(onError).toHaveBeenCalledWith(expect.any(Error), 'error') + expect(onError.mock.calls[0]?.[0]).toMatchObject({ + cause: expect.objectContaining({ message: 'SECRET_TOKEN close failure' }), + }) + }) + it('does not finish disposal before the managed tree exits', async () => { const child = fakeChild({ exitOnTerminate: false }) let disposed = false From 3621716704fe5e88317a5c498f25e229194b2626 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 05:37:14 +0800 Subject: [PATCH 064/137] test(subagent): type Claude failure cause assertions --- .../tests/subagent-claude-code.spec.ts | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index b853dc9004..c2b4a033e9 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -76,6 +76,12 @@ async function nextTask(): Promise { await new Promise((resolve) => { setImmediate(resolve) }) } +function errorCause(value: unknown): Error | undefined { + return value instanceof Error && value.cause instanceof Error + ? value.cause + : undefined +} + interface FakeChildOptions { readonly pid?: number readonly exitOnTerminate?: boolean @@ -423,9 +429,8 @@ describe('task admission and package contracts', () => { 'subagent-claude-code: child start failed: %o', expect.any(Error), ) - expect(warn.mock.calls[0]?.[1]).toMatchObject({ - cause: expect.objectContaining({ message: 'claude missing from PATH' }), - }) + expect(errorCause(warn.mock.calls[0]?.[1] as unknown)?.message) + .toBe('claude missing from PATH') expect(queryMock).not.toHaveBeenCalled() const resolutionAbort = new AbortController() @@ -1165,7 +1170,9 @@ describe('run publication, cancellation, and settlement', () => { queryMock.mockImplementationOnce(() => { throw new Error('query failed before resource creation') }) - const queryFailureOnError = vi.fn() + const queryFailureOnError = vi.fn< + NonNullable + >() const queryFailure = startClaudeCodeRun(request(), { ...unused.spec, onError: queryFailureOnError, @@ -1179,11 +1186,8 @@ describe('run publication, cancellation, and settlement', () => { expect.any(Error), 'error', ) - expect(queryFailureOnError.mock.calls[0]?.[0]).toMatchObject({ - cause: expect.objectContaining({ - message: 'query failed before resource creation', - }), - }) + expect(errorCause(queryFailureOnError.mock.calls[0]?.[0])?.message) + .toBe('query failed before resource creation') const spawned = fakeChild() const spawnSpecs: SubprocessSpawnSpec[] = [] @@ -1262,7 +1266,7 @@ describe('query and process disposal', () => { it('reports a published teardown failure to the Host diagnostic sink', async () => { const fixture = fakeRun([success('exact answer')]) - const onError = vi.fn() + const onError = vi.fn>() const run = await startClaudeCodeRun(request(), { ...fixture.spec, onError, @@ -1278,9 +1282,8 @@ describe('query and process disposal', () => { }), ) expect(onError).toHaveBeenCalledWith(expect.any(Error), 'error') - expect(onError.mock.calls[0]?.[0]).toMatchObject({ - cause: expect.objectContaining({ message: 'SECRET_TOKEN close failure' }), - }) + expect(errorCause(onError.mock.calls[0]?.[0])?.message) + .toBe('SECRET_TOKEN close failure') }) it('does not finish disposal before the managed tree exits', async () => { From 884f7b9c41f292b1715c79399eb8aec04843df4d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 11:24:33 +0800 Subject: [PATCH 065/137] fix(llm-pi-ai): expose the pi-ai wire-compat surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi-ai infers a request's shape from the provider id and baseURL, and for an endpoint it does not recognize it answers as though it were OpenAI itself. A hand-declared route is by construction such an endpoint, so a model declaring reasoningEfforts sent its system prompt as the developer role with no configuration able to say otherwise — a gateway rejecting that role could not be connected at all. Writing the switch anyway validated, persisted, and was then dropped, so the misconfiguration looked applied. Three drift gates classify all thirty upstream compat fields as offered or withheld, keyed by `keyof` so a pi-ai upgrade fails the build until the new field is classified. Twenty are offered: what a private URL cannot imply. The rest stay withheld because pi-ai's installed catalog sets them for a named vendor. Protocol applicability is now per field rather than per block, so supportsDeveloperRole reaches an openai-responses route and the anthropic-messages switches reach theirs. A compat key no protocol declares, or one a gate withholds, is refused where it is written. Fixes #2646 Refs #1976 --- ...per-model-reasoning-declarations.i18n.yaml | 4 +- ...-pi-ai-per-model-reasoning-declarations.md | 2 +- ...-ai-per-model-reasoning-declarations.zh.md | 2 +- ...-08-18-pi-ai-wire-compat-surface.i18n.yaml | 6 + .../2026-08-18-pi-ai-wire-compat-surface.md | 51 +++ ...2026-08-18-pi-ai-wire-compat-surface.zh.md | 51 +++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 92 +++-- docs/config-catalog.zh.md | 92 +++-- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 11 +- packages/llm/llm-pi-ai/README.zh.md | 11 +- packages/llm/llm-pi-ai/src/catalog.ts | 355 +++++++++++++++--- packages/llm/llm-pi-ai/src/config.ts | 47 ++- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 40 ++ packages/llm/llm-pi-ai/tests/catalog.spec.ts | 123 +++++- 16 files changed, 762 insertions(+), 133 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml index 3639c8da6b..b7f4e2e031 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.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-pi-ai-per-model-reasoning-declarations.md -2026-08-08-pi-ai-per-model-reasoning-declarations.md: b6264feeb724e3693078fa3fc3e3fc16ed01aacb -2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: 1b30f7e0c42974c777a535e133a47caa217e2e5e +2026-08-08-pi-ai-per-model-reasoning-declarations.md: 0e8d5c3ca4017e89332f6b22e4eb0a06062918e6 +2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md: c4c060b21c8b7ee5b11b5e98c8d7da0ba6c032fc diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md index b6264feeb7..0e8d5c3ca4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.md @@ -14,7 +14,7 @@ Two adjacent gaps compounded this. pi-ai decides the reasoning *wire dialect* (` `PiAiModelProfile` gains `reasoningEfforts`: **each key is a level selectors offer, its value the spelling dispatch sends on the wire**. The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with all seven levels decided explicitly — declared levels carry their wire value, undeclared levels are pinned `null` — so the profile author never needs pi-ai's asymmetric defaulting rule (absent means "supported" for the five base levels but "unsupported" for `xhigh`/`max`). `off` is the one three-state key: left out, no Off is offered and an explicit Off request is refused (an effortless request still goes out bare, leaving the provider its default); declared valueless, Off is offered and dispatch sends nothing (the `deepseek` dialect sends `thinking: {type: "disabled"}`); declared with a value, that value goes on the wire. `false` declares a non-reasoning model; an empty declaration is refused rather than guessed at. The spelling for "disable" is `false` rather than `{}` because schemastery materializes an absent dict as `{}` — only a `z.union([z.const(false), dict])` keeps absent, disabled, and declared distinguishable, and a bare `reasoningEfforts:` (YAML null) slips through that union unvalidated, so resolution refuses it explicitly. -`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. They exist only on `openai-completions` (pi-ai types them nowhere else): a model-level switch on another protocol fails resolution, a route-level default skips such models, and a route with no completions model at all is refused. The two `chat-template` formats stay withheld for want of `chatTemplateKwargs`. Both enums are pinned to pi-ai's types through `Record` drift gates, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). +`compat.thinkingFormat` and `compat.supportsReasoningEffort` become configurable at two levels — route (its models' default) and model (winning per field) — resolving model → route → installed catalog entry → pi-ai's URL guess. `thinkingFormat` is pinned to pi-ai's union through a `Record` drift gate, so a pi-ai upgrade that adds a format fails compilation until the new member is classified (verified against the published 0.84.1 tarball, whose `thinkingFormat` union adds `baseten` over the pinned 0.82.1). Which fields `compat` carries, which protocols take each of them, and how an unreadable key is refused are owned by [[2026-08-18-pi-ai-wire-compat-surface]]; the two-level resolution order above is what that surface generalizes. `modelOverrides` reshapes individual catalog models without replacing the served set: key = catalog model id, value = a `models` entry minus `id`, materialized by handing the override to the existing entry path so capacities, efforts, compat, and request-default semantics stay identical. Unlike Pi's own config layer, which ignores unknown ids, every override that lands nowhere is refused — beside a `models` list, on a hand-declared route, naming an unknown model, or smuggling an `id` in the value (the schema passes unknown keys through, and a smuggled id would quietly rename the model). diff --git a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md index 1b30f7e0c4..c4c060b21c 100644 --- a/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-pi-ai-per-model-reasoning-declarations.zh.md @@ -14,7 +14,7 @@ Status: implemented `PiAiModelProfile` 新增 `reasoningEfforts`:**每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写**。该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,七个档位全部显式决定——已声明的档位携带自己的协议值,未声明的档位一律固定为 `null`——因此 profile 作者永远不需要了解 pi-ai 那条不对称的默认规则(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」)。`off` 是唯一的三态键:不写,选择器不提供 Off,显式请求 Off 会被拒绝(不点名档位的请求仍会不带参数地发出,提供方保留自己的默认行为);声明而不给值,则提供 Off,分派什么也不发送(`deepseek` 方言发送 `thinking: {type: "disabled"}`);声明并给值,该值就在协议中发送。`false` 声明一个不具备推理能力的模型;空声明会被拒绝,而不是去猜。「禁用」的拼写取 `false` 而非 `{}`,因为 schemastery 会把缺席的字典物化成 `{}`——只有 `z.union([z.const(false), dict])` 才能让缺席、禁用与已声明三态保持可区分;而裸写的 `reasoningEfforts:`(YAML null)会不经校验地从该 union 溜过去,因此解析对它显式拒绝。 -`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。两者只存在于 `openai-completions` 上(pi-ai 也只在这一协议上为它们建了类型):在其他协议的模型上设模型级开关会使解析失败,路由级默认值会跳过这类模型,而完全没有 completions 模型的路由则被拒绝。两个 `chat-template` 格式因缺 `chatTemplateKwargs` 而继续保持不开放。两个枚举都经 `Record` 漂移门禁钉在 pi-ai 的类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。 +`compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 变为两级可配置——路由级(作为其模型的默认值)与模型级(逐字段胜出)——解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测。`thinkingFormat` 经 `Record` 漂移门禁钉在 pi-ai 的联合类型上,因此新增格式的 pi-ai 升级会编译失败,直到新成员被归类(对照已发布的 0.84.1 tarball 验证过:其 `thinkingFormat` 联合类型相对钉住的 0.82.1 新增了 `baseten`)。`compat` 承载哪些字段、每个字段由哪些协议接受、以及无法读取的键如何被拒绝,归 [[2026-08-18-pi-ai-wire-compat-surface]] 所有;上面这条两级解析顺序正是该面所推广的东西。 `modelOverrides` 就地重塑单个 catalog 模型而不替换所服务的集合:键 = catalog 模型 id,值 = 去掉 `id` 的 `models` 条目,物化时把覆盖交给既有的条目路径,因此容量、档位、compat 与请求默认值语义完全一致。与忽略未知 id 的 Pi 自有配置层不同,凡是落不到任何地方的覆盖都会被拒绝——与 `models` 列表并存、写在手工声明的路由上、点名未知模型,或在值里夹带 `id`(schema 会放行未知键,被夹带的 id 会悄悄把模型改名)。 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml new file mode 100644 index 0000000000..0a36513613 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.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-18-pi-ai-wire-compat-surface.md +2026-08-18-pi-ai-wire-compat-surface.md: c7e0bc75806e8ba022db9c9f17cfe2b621c21611 +2026-08-18-pi-ai-wire-compat-surface.zh.md: c592044e239421110813feb364bacc142a9d2d32 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md new file mode 100644 index 0000000000..c7e0bc7580 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md @@ -0,0 +1,51 @@ +# Agent Note: pi-ai Wire-Compatibility Surface in llm-pi-ai + +Status: implemented + +English | [中文](2026-08-18-pi-ai-wire-compat-surface.zh.md) + +## Problem + +pi-ai shapes every request from the provider id and the baseURL — which role carries the system prompt, which field caps output, whether `store` and `stream_options` go out, whether tool definitions carry `strict`. For an endpoint its detection does not recognize, the answer is "this is OpenAI itself": `detectCompat` returns `supportsDeveloperRole: true`, `maxTokensField: "max_completion_tokens"`, `supportsStore: true`. A hand-declared route is by construction an endpoint pi-ai does not ship, so every such route received OpenAI's own request shape. + +The adapter offered two of pi-ai's thirty compat fields ([[2026-08-08-pi-ai-per-model-reasoning-declarations]] scoped them to "the switches pi-ai's reasoning dispatch reads"), and `supportsDeveloperRole` fell inside that scope while being absent from it: its send site is `model.reasoning && compat.supportsDeveloperRole`. A hand-declared model declaring `reasoningEfforts` therefore sent its system prompt as `role: "developer"`, which most OpenAI-compatible gateways reject, and no configuration could say otherwise — the gateway could not be connected at all. + +Writing the field anyway was worse than unsupported. schemastery passes unknown keys through, and resolution read only two names, so `compat: {supportsDeveloperRole: false}` validated, persisted, and was then dropped: the operator saw an accepted write and an unchanged failure. `maxTokensField` carried the same defect over a wider blast radius, since it shapes every request rather than only a reasoning model's. + +## Decision + +Three drift gates — one per pi-ai compat type, keyed `Record` — classify all thirty upstream fields as `offer` or `withhold`. Twenty are offered. The line is what a private URL can imply: a deployment must be able to state what nothing can infer from an unrecognized endpoint, while a field pi-ai's installed catalog sets for a named vendor stays withheld, because a route reaching for `openRouterRouting` or `deferredToolsMode` is a catalog route that should be named as such and inherit the value. + +`PiAiCompatProfile` stays an explicit interface with per-field JSDoc — it is what a configuration surface renders and what `docs/config-catalog.md` pastes — and a type-level `AssertNever` over the symmetric difference proves it names exactly the offered set. The schemastery schema is declared `z`, so the four faces lock together: an upstream field added, a gate entry missing, an interface field forgotten, or a schema key omitted each fails compilation naming the field. + +Protocol applicability is per field rather than per block. `supportsDeveloperRole` is settable wherever pi-ai declares it (`openai-completions` and `openai-responses`), `thinkingFormat` only on the former, `supportsTemperature` only on `anthropic-messages`. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it. `chatTemplateKwargs` is offered, which is what makes the two `chat-template` thinking formats nameable. + +A `compat` key no protocol declares, and one a gate withholds, are both refused where they are written rather than dropped. The check runs over every key before any protocol resolves, so a misspelling fails even on a route whose models never reach the protocol that would have taken it. It reads raw keys deliberately: a withheld or undeclared name is absent from the schema, so schemastery cannot have materialized it and a person wrote it. Fields carrying a value are then filtered separately, because schemastery materializes an absent dict as `{}` and `chatTemplateKwargs` is present on every parsed profile whether or not anyone wrote one. + +## Where a refusal lands + +Every check runs in `resolveProfiles`, which no request path re-enters: the adapter memoizes by raw-snapshot identity and `apply` resolves once eagerly. A refusal therefore reaches `settings.mutate` as `settings-rejected` before persistence, a `cordis.yml` `config:` block as a failed plugin mount, and a stored section as a failed `settings.register` at startup. + +An external edit to the settings file is the one path that cannot report: the provider watcher calls `publish()`, which catches a failing section, logs `settings: keeping last good "%s"`, and leaves the namespace serving its previous value. That is the settings seam's behavior for every schema and validator failure, not something this surface introduces, and closing it belongs to that seam rather than here. What changes for compat is the failure model, not the reporting: a key that formerly stayed inert forever now stops the next start. + +## Alternatives considered + +**Add `supportsDeveloperRole` alone.** It fixes the reported gateway and leaves `maxTokensField` — which shapes every request, not only a reasoning model's — breaking a whole class of endpoints, with the next upstream addition free to lag silently again. + +**Offer every upstream field.** pi-ai's own custom-provider documentation converges on a far smaller set, its flagship example naming six, and the remainder are vendor-bound switches its catalog already sets. Exposing `zaiToolStream` or `vercelGatewayRouting` on a hand-declared route offers a knob whose correct use is to not be a hand-declared route. + +**Key `compat` by protocol** (`compat: {openai-completions: {…}}`). A hand-declared route has exactly one `api`, so the nesting states what the route already said, and it breaks every profile written against the flat shape for nothing. + +**Accept an opaque passthrough dict.** The schema is also the shape a configuration surface renders and the declaration `verify-config-catalog` cross-checks, both of which an unstructured dict defeats; it would also let a responses-only field land on a completions model, which per-field applicability exists to refuse. + +**Warn instead of refusing an unknown key.** That is the posture that hid this defect for the life of the surface: an accepted write and an unchanged failure teaches the operator that the switch does not work, not that the name is wrong. + +**Suggest a near spelling on an unknown key.** No repository utility computes edit distance, and adding a dependency or hand-rolling one under the per-file coverage gate is disproportionate for a diagnostic. Naming the offered fields answers the same question deterministically: the vocabulary check runs before any protocol resolves, so it names the whole offered set, while the per-protocol refusal narrows to what that protocol takes. + +## Consequences + +- An OpenAI-compatible gateway that rejects the `developer` role, `max_completion_tokens`, `store`, `stream_options`, or `strict` is now configuration rather than an unreachable provider, and the same holds for an Anthropic-compatible gateway rejecting `temperature` or tool `cache_control`. +- A pi-ai upgrade that adds a compat field fails the build until someone classifies it, which is how `chatTemplateKwargs` and the `chat-template` formats stopped being a standing exception. +- Unknown compat keys join every other configuration error's failure model. The improvement over the previous silent drop is bounded by the settings seam: an external file edit still keeps its last good value and warns, so the operator's signal is a restart rather than the write. +- **Deferred, not closed:** `publish()` reports a rejected stored section only through `ctx.logger.warn`, with no user-visible channel. It affects every settings namespace and is owned by `dsh-settings`. +- [[2026-08-08-pi-ai-per-model-reasoning-declarations]] is partially superseded: its compat-scope statements are restated here, while its `reasoningEfforts` shape, the alternatives that shape beat, and `modelOverrides` remain the current authority. diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md new file mode 100644 index 0000000000..c592044e23 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md @@ -0,0 +1,51 @@ +# Agent Note: pi-ai Wire-Compatibility Surface in llm-pi-ai + +Status: implemented + +[English](2026-08-18-pi-ai-wire-compat-surface.md) | 中文 + +## Problem + +pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提示词由哪个角色承载、输出上限写在哪个字段、是否发出 `store` 与 `stream_options`、工具定义是否携带 `strict`。对于其检测无法识别的端点,答案是「这就是 OpenAI 本身」:`detectCompat` 返回 `supportsDeveloperRole: true`、`maxTokensField: "max_completion_tokens"`、`supportsStore: true`。而手工声明的路由按其构造就是 pi-ai 未随附的端点,于是每一条这样的路由都收到了 OpenAI 自己的请求形状。 + +适配器只开放了 pi-ai 三十个 compat 字段中的两个([[2026-08-08-pi-ai-per-model-reasoning-declarations]] 把它们限定为「pi-ai 推理分派读取的那些开关」),而 `supportsDeveloperRole` 恰恰落在该作用域之内却不在其中:它的发送点是 `model.reasoning && compat.supportsDeveloperRole`。因此一个声明了 `reasoningEfforts` 的手工声明模型会把系统提示词以 `role: "developer"` 发出——多数 OpenAI 兼容网关会拒绝该角色——而没有任何配置能够更正,该网关根本接不进来。 + +硬写这个字段比不支持更糟。schemastery 会放行未知键,而解析只读取两个名字,于是 `compat: {supportsDeveloperRole: false}` 通过校验、落盘,随后被丢弃:运维看到的是一次被接受的写入和一个毫无变化的故障。`maxTokensField` 带着同一缺陷、却有更大的波及面,因为它塑造每一个请求,而不只是推理模型的请求。 + +## Decision + +三张漂移门禁——每个 pi-ai compat 类型一张,以 `Record` 为键——把全部三十个上游字段分类为 `offer` 或 `withhold`,其中二十个开放。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。 + +`PiAiCompatProfile` 保持为带逐字段 JSDoc 的显式 interface——它是配置界面所渲染、也是 `docs/config-catalog.md` 所粘贴的东西——并由一个作用在对称差上的类型级 `AssertNever` 证明它恰好命名了开放集。schemastery schema 声明为 `z`,于是四个面互锁:上游新增字段、门禁漏一条、interface 忘记一个字段、schema 少一个键,都会在编译期以点名该字段的方式失败。 + +协议适用性逐字段判断,而非整块判断。`supportsDeveloperRole` 在 pi-ai 声明它的任何地方均可设置(`openai-completions` 与 `openai-responses`),`thinkingFormat` 只在前者,`supportsTemperature` 只在 `anthropic-messages`。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 予以开放,这正是两个 `chat-template` 思考格式得以命名的前提。 + +没有任何协议声明的 `compat` 键,以及被门禁扣留的键,都在其被写下之处遭到拒绝而非丢弃。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,`chatTemplateKwargs` 都出现在每一个解析过的 profile 上。 + +## Where a refusal lands + +所有检查都在 `resolveProfiles` 中运行,而请求路径不会重新进入它:适配器按原始快照的标识 memoize,且 `apply` 会主动预先解析一次。因此一次拒绝会以 `settings-rejected` 的形式在落盘之前抵达 `settings.mutate`,以插件挂载失败的形式抵达 `cordis.yml` 的 `config:` 块,以 `settings.register` 启动失败的形式抵达已存的 section。 + +对 settings 文件的外部编辑是唯一无法报告的路径:提供方监听器调用 `publish()`,它捕获失败的 section、记录 `settings: keeping last good "%s"`,并让该 namespace 继续服务其先前的值。这是 settings seam 对每一种 schema 与校验器失败的既有行为,并非本次开放引入,弥合它属于那个 seam 而不属于此处。对 compat 而言改变的是失败模型而非报告方式:一个从前永远静默无效的键,如今会拦下下一次启动。 + +## Alternatives considered + +**只补 `supportsDeveloperRole`。** 它修好了报告中的那个网关,却放任 `maxTokensField`——它塑造每一个请求,而不只是推理模型的请求——继续拖垮一整类端点,而且下一个上游新增字段依然可以静默落后。 + +**开放全部上游字段。** pi-ai 自己的 custom-provider 文档收敛到一个小得多的集合,其旗舰示例只点名六个,其余都是其 catalog 已经设定好的厂商绑定开关。在手工声明路由上暴露 `zaiToolStream` 或 `vercelGatewayRouting`,等于提供一个「正确用法是别做手工声明路由」的旋钮。 + +**把 `compat` 按协议分层**(`compat: {openai-completions: {…}}`)。手工声明路由恰好只有一个 `api`,因此这层嵌套只是复述路由已经说过的事,还白白破坏了所有按扁平形状写下的 profile。 + +**接受一个不透明的透传 dict。** 该 schema 同时是配置界面渲染的形状、也是 `verify-config-catalog` 交叉校验的声明,无结构的 dict 会同时击溃两者;它还会让 responses 独有的字段落到 completions 模型上,而逐字段适用性正是为拒绝这种情况而存在。 + +**未知键只告警不拒绝。** 这恰恰是让本缺陷伴随该面存活至今的姿态:一次被接受的写入加一个毫无变化的故障,教给运维的是「这个开关没用」,而不是「这个名字写错了」。 + +**为未知键给出近似拼写建议。** 仓库中没有计算编辑距离的工具,在逐文件覆盖率门禁之下为一条诊断引入依赖或手搓一个都不成比例。点名开放字段能确定地回答同一个问题:词汇检查跑在任何协议解析之前,因此它列出整个开放集,而按协议的拒绝则收窄到该协议实际接受的字段。 + +## Consequences + +- 拒绝 `developer` 角色、`max_completion_tokens`、`store`、`stream_options` 或 `strict` 的 OpenAI 兼容网关,如今属于配置问题而非无法接入的提供方;拒绝 `temperature` 或工具 `cache_control` 的 Anthropic 兼容网关同理。 +- pi-ai 升级新增 compat 字段会使构建失败,直到有人为它做出分类——`chatTemplateKwargs` 与那两个 `chat-template` 格式正是因此不再是一项长期例外。 +- 未知 compat 键并入了其余所有配置错误的失败模型。相对此前静默丢弃的改善程度受 settings seam 限制:外部文件编辑仍会保留其上一个有效值并告警,因此运维拿到的信号是一次重启,而不是那次写入。 +- **搁置而非解决:** `publish()` 对被拒绝的已存 section 只通过 `ctx.logger.warn` 报告,没有面向用户的通道。它影响每一个 settings namespace,归属 `dsh-settings`。 +- [[2026-08-08-pi-ai-per-model-reasoning-declarations]] 被部分取代:其 compat 作用域的陈述在此重述,而其 `reasoningEfforts` 形状、该形状所击败的备选方案以及 `modelOverrides` 仍是当前权威。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4fdaf689b8..1f371748b7 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: 9e3dc2b14a04877c191227703752d0b1dbb2b92b -config-catalog.zh.md: 56c1fd075b680c79c7730b9bdcb1275e83bb3f64 +config-catalog.md: ad9bb6284354b934bd6e0bf3235dcd0b74f33cc5 +config-catalog.zh.md: 7038b951a14115873a032fab0066be2a990e3a3f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9e3dc2b14a..ad9bb62843 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -938,10 +938,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -1022,7 +1023,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -1036,19 +1037,65 @@ export interface PiAiModelProfile { export type PiAiModelOverride = Omit /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * default) or per model (winning over the route). Only the switches pi-ai's - * reasoning dispatch reads are offered; the rest of pi-ai's compat surface - * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols define their reasoning - * fields in the protocol itself — so resolution rejects a model-level switch - * anywhere else, while a route-level default skips past models it cannot fit. + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ - thinkingFormat?: PiAiThinkingFormat - /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions`, `openai-responses`. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: 'max_completion_tokens' | 'max_tokens' + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** Kwargs sent as `chat_template_kwargs`, for the two `chat-template` thinking formats; `openai-completions`. */ + chatTemplateKwargs?: Record + /** Whether the endpoint accepts `strict` in tool definitions; `openai-completions`, `openai-responses`. */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: 'anthropic' + /** Whether the endpoint accepts long prompt-cache retention; all three protocols. */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean } /** One request modality a pi-ai model may accept. */ @@ -1065,21 +1112,12 @@ export type PiAiModality = Model['input'][number] export type PiAiReasoningEfforts = Partial> /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude - -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' +export type PiAiThinkingFormat = NonNullable ``` -Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `ChatTemplateKwargValue` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:172`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:173`](../packages/llm/llm-pi-ai/src/config.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 56c1fd075b..7038b951a1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -940,10 +940,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -1024,7 +1025,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -1038,19 +1039,65 @@ export interface PiAiModelProfile { export type PiAiModelOverride = Omit /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * default) or per model (winning over the route). Only the switches pi-ai's - * reasoning dispatch reads are offered; the rest of pi-ai's compat surface - * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols define their reasoning - * fields in the protocol itself — so resolution rejects a model-level switch - * anywhere else, while a route-level default skips past models it cannot fit. + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ - thinkingFormat?: PiAiThinkingFormat - /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions`, `openai-responses`. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: 'max_completion_tokens' | 'max_tokens' + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** Kwargs sent as `chat_template_kwargs`, for the two `chat-template` thinking formats; `openai-completions`. */ + chatTemplateKwargs?: Record + /** Whether the endpoint accepts `strict` in tool definitions; `openai-completions`, `openai-responses`. */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: 'anthropic' + /** Whether the endpoint accepts long prompt-cache retention; all three protocols. */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean } /** One request modality a pi-ai model may accept. */ @@ -1067,21 +1114,12 @@ export type PiAiModality = Model['input'][number] export type PiAiReasoningEfforts = Partial> /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude - -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' +export type PiAiThinkingFormat = NonNullable ``` -依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) +依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `ChatTemplateKwargValue`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) -来源:[`packages/llm/llm-pi-ai/src/config.ts:172`](../packages/llm/llm-pi-ai/src/config.ts) +来源:[`packages/llm/llm-pi-ai/src/config.ts:173`](../packages/llm/llm-pi-ai/src/config.ts) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 553b7d4557..c4f1dd2fa0 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: d775e72616822ce0deee063ac0f3fc453af1a126 -README.zh.md: 621d67d1c181c6d4c78ea0078f521acccce92653 +README.md: 3251cc7e71e343fa82ab76f95fe341949a0836c6 +README.zh.md: b62a4069e9f5c57533054d439fd63b9cbd6f44cd diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index d775e72616..3251cc7e71 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -51,9 +51,12 @@ Configure credentials, the model catalog, and deployment-specific transport sett apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 - # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + # Request shape for an endpoint whose URL pi-ai cannot recognize; it + # would otherwise be addressed as though it were OpenAI itself. compat: thinkingFormat: deepseek + supportsDeveloperRole: false + maxTokensField: max_tokens models: - id: acme-large name: Acme Large @@ -85,9 +88,11 @@ A profile's `models` list *replaces* the route's installed catalog rather than e The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, selectors offer no Off and an explicit Off request is refused — a request naming no effort still goes out without the parameter, so what the provider then does is its own default; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep. -### Reasoning-dispatch compat switches +### Wire-compatibility switches -How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking: {type}` plus effort, z.ai's `thinking` object, and so on — is pi-ai's `compat.thinkingFormat`, which pi-ai guesses from the endpoint URL; a private gateway's URL says nothing, so a DeepSeek-dialect gateway would be spoken to in the OpenAI dialect with no way to correct it. `compat.thinkingFormat` and `compat.supportsReasoningEffort` are therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's URL-derived guess; setting a route-level switch shadows the catalog entry's value for every model on the route, and there is no spelling for handing a field back to the catalog short of restating its value. `thinkingFormat` accepts pi-ai's dispatchable formats except the two `chat-template` variants, which need `chatTemplateKwargs` this configuration does not expose. Both switches exist only on `openai-completions` — the other protocols carry their reasoning shape in the protocol itself — so a model-level switch elsewhere fails resolution, a route-level one skips models of other protocols, and a route with no `openai-completions` model at all is refused. The rest of pi-ai's compat surface (`supportsStore`, `maxTokensField`, …) stays auto-detected and is deliberately not configurable here. +pi-ai shapes each request from the provider id and baseURL: which role carries the system prompt, which field caps output, how a thinking level travels. A private gateway's URL says nothing, and for an endpoint pi-ai does not recognize the detection answers as though it were OpenAI itself — a reasoning model's system prompt goes out as `developer`, the output cap as `max_completion_tokens`, the thinking level as a bare `reasoning_effort` — and most OpenAI-compatible gateways reject at least one of those. `compat` is therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's own detection; a route-level switch shadows the catalog entry's value for every model that reads it, and there is no spelling for handing a field back to the catalog short of restating its value. + +Each switch belongs to the protocols whose pi-ai compat type declares it: `supportsDeveloperRole` is settable on an `openai-completions` or `openai-responses` route, `thinkingFormat` only on the former, `supportsTemperature` only on `anthropic-messages`. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it at all. Two kinds of key are refused rather than dropped: one no protocol declares (a misspelling), and one pi-ai's installed catalog owns for a named vendor (`openRouterRouting`, `zaiToolStream`, `deferredToolsMode`, `sessionAffinityFormat`, `supportsOpenAIGrammarTools`, `supportsToolSearch`, `supportsExplicitPromptCacheMode`, `supportsToolReferences`, `vercelGatewayRouting`, `sendSessionAffinityHeaders`) — a route needing a vendor's own switch is a catalog route that should be named as such. The offered set is pinned to pi-ai's three compat types by drift gates, so an upgrade adding a field fails the build until someone classifies it. A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 621d67d1c1..b62a4069e9 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -51,9 +51,12 @@ apiKeyEnv: ACME_GATEWAY_API_KEY api: openai-completions baseURL: https://gateway.acme.example/v1 - # Reasoning dialect for an endpoint whose URL pi-ai cannot recognize. + # Request shape for an endpoint whose URL pi-ai cannot recognize; it + # would otherwise be addressed as though it were OpenAI itself. compat: thinkingFormat: deepseek + supportsDeveloperRole: false + maxTokensField: max_tokens models: - id: acme-large name: Acme Large @@ -85,9 +88,11 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,选择器不提供 Off,显式请求 Off 会被拒绝——不点名任何档位的请求仍会在不带该参数的情况下发出,提供方随后做什么是它自己的默认行为;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。 -### 推理分派的 compat 开关 +### 协议兼容开关 -思考级别如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加上档位、z.ai 的 `thinking` 对象,诸如此类——就是 pi-ai 的 `compat.thinkingFormat`,pi-ai 会从端点 URL 猜测它;私有网关的 URL 什么也说明不了,于是说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且无从更正。因此 `compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测;设置路由级开关会为路由上的每个模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。`thinkingFormat` 接受 pi-ai 可分派的各种格式,但不含两个 `chat-template` 变体:它们需要的 `chatTemplateKwargs` 本配置并不暴露。两个开关都只存在于 `openai-completions` 上——其余协议的推理形状由协议本身承载——因此在其他协议的模型上设置模型级开关会使解析失败,路由级开关会跳过其他协议的模型,而完全没有 `openai-completions` 模型的路由则会被拒绝。pi-ai compat 面的其余部分(`supportsStore`、`maxTokensField`……)保持自动检测,特意不在此处开放配置。 +pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输。私有网关的 URL 什么也说明不了,而对于 pi-ai 无法识别的端点,其检测会当作 OpenAI 本身来回答——推理模型的系统提示词以 `developer` 发出、输出上限写作 `max_completion_tokens`、思考级别只发一个裸的 `reasoning_effort`——而多数 OpenAI 兼容网关至少会拒绝其中之一。因此 `compat` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 自身的检测;路由级开关会为每个读取它的模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。 + +每个开关归属于其 pi-ai compat 类型声明了它的那些协议:`supportsDeveloperRole` 可设在 `openai-completions` 或 `openai-responses` 路由上,`thinkingFormat` 只能设在前者,`supportsTemperature` 只能设在 `anthropic-messages` 上。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。两类键会被拒绝而非丢弃:没有任何协议声明的键(笔误),以及 pi-ai 已安装 catalog 为具名厂商掌管的键(`openRouterRouting`、`zaiToolStream`、`deferredToolsMode`、`sessionAffinityFormat`、`supportsOpenAIGrammarTools`、`supportsToolSearch`、`supportsExplicitPromptCacheMode`、`supportsToolReferences`、`vercelGatewayRouting`、`sendSessionAffinityHeaders`)——需要某厂商专属开关的路由,本就是一条应当以该厂商命名的 catalog 路由。开放集由漂移门禁钉在 pi-ai 的三个 compat 类型上,因此上游新增字段会使构建失败,直到有人为它做出分类。 条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成单次请求上限。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 7a4ff7e8a2..5a42ac9220 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -15,11 +15,14 @@ import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all' import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' import type { + AnthropicMessagesCompat, Api, + ChatTemplateKwargValue, Model, ModelCost, ModelThinkingLevel, OpenAICompletionsCompat, + OpenAIResponsesCompat, Provider, ThinkingLevelMap, } from '@earendil-works/pi-ai' @@ -79,23 +82,16 @@ const THINKING_LEVEL_GATE: Record = { /** Every pi-ai thinking level a profile may declare, in escalation order. */ export const THINKING_LEVELS = Object.keys(THINKING_LEVEL_GATE) as readonly ModelThinkingLevel[] -/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */ -type PiThinkingFormat = NonNullable - -/** - * pi-ai thinking formats a profile cannot name: both drive the request through - * `chatTemplateKwargs`, which this configuration does not expose. - */ -type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template' - /** One reasoning-dispatch wire format a profile may name. */ -export type PiAiThinkingFormat = Exclude +export type PiAiThinkingFormat = NonNullable /** * The nameable reasoning-dispatch formats, most-reached first. The `Record` * key type is a drift gate: a pi-ai upgrade that adds a format (0.84 added - * `baseten`) fails compilation here until the format is classified as offered - * here or withheld above, so the offer never silently lags the upstream set. + * `baseten`) fails compilation here until the new format is named, so the + * offer never silently lags the upstream set. The two `chat-template` variants + * are nameable because {@link PiAiCompatProfile.chatTemplateKwargs} carries + * the kwargs they dispatch through. */ const THINKING_FORMAT_GATE: Record = { 'openai': true, @@ -104,6 +100,8 @@ const THINKING_FORMAT_GATE: Record = { 'together': true, 'zai': true, 'qwen': true, + 'chat-template': true, + 'qwen-chat-template': true, 'string-thinking': true, 'ant-ling': true, } @@ -183,19 +181,251 @@ export function catalogModels(provider: string): Map> { export type PiAiReasoningEfforts = Partial> /** - * Reasoning-dispatch compatibility switches, set on the route (its models' - * default) or per model (winning over the route). Only the switches pi-ai's - * reasoning dispatch reads are offered; the rest of pi-ai's compat surface - * keeps its baseURL-derived auto-detection. pi-ai types both fields only on - * `OpenAICompletionsCompat` — the other wire protocols define their reasoning - * fields in the protocol itself — so resolution rejects a model-level switch - * anywhere else, while a route-level default skips past models it cannot fit. + * Whether one pi-ai compat field is configurable on a profile. + * + * `withhold` is the disposition for a field pi-ai's installed catalog already + * sets for a named vendor. Reaching for one of those on a hand-declared route + * means configuring a provider that should have been named as a catalog route + * instead, where the installed entry carries the right value already. + */ +type CompatDisposition = 'offer' | 'withhold' + +/** + * Disposition of every `OpenAICompletionsCompat` field. The `Record` key type + * is a drift gate: a pi-ai upgrade that adds a field fails compilation here + * until it is classified, so the offer never silently lags the upstream set. + */ +const COMPLETIONS_COMPAT_GATE = { + supportsStore: 'offer', + supportsDeveloperRole: 'offer', + supportsReasoningEffort: 'offer', + supportsUsageInStreaming: 'offer', + maxTokensField: 'offer', + requiresToolResultName: 'offer', + requiresAssistantAfterToolResult: 'offer', + requiresThinkingAsText: 'offer', + requiresReasoningContentOnAssistantMessages: 'offer', + thinkingFormat: 'offer', + chatTemplateKwargs: 'offer', + supportsStrictMode: 'offer', + cacheControlFormat: 'offer', + supportsLongCacheRetention: 'offer', + openRouterRouting: 'withhold', + vercelGatewayRouting: 'withhold', + zaiToolStream: 'withhold', + supportsOpenAIGrammarTools: 'withhold', + sendSessionAffinityHeaders: 'withhold', + deferredToolsMode: 'withhold', + sessionAffinityFormat: 'withhold', +} as const satisfies Record + +/** Disposition of every `OpenAIResponsesCompat` field; a drift gate like the one above. */ +const RESPONSES_COMPAT_GATE = { + supportsDeveloperRole: 'offer', + supportsStrictMode: 'offer', + supportsLongCacheRetention: 'offer', + sessionAffinityFormat: 'withhold', + supportsOpenAIGrammarTools: 'withhold', + supportsToolSearch: 'withhold', + supportsExplicitPromptCacheMode: 'withhold', +} as const satisfies Record + +/** Disposition of every `AnthropicMessagesCompat` field; a drift gate like the one above. */ +const ANTHROPIC_COMPAT_GATE = { + supportsEagerToolInputStreaming: 'offer', + supportsLongCacheRetention: 'offer', + supportsCacheControlOnTools: 'offer', + supportsTemperature: 'offer', + forceAdaptiveThinking: 'offer', + allowEmptySignature: 'offer', + supportsStrictTools: 'offer', + sendSessionAffinityHeaders: 'withhold', + supportsToolReferences: 'withhold', +} as const satisfies Record + +/** + * The compat gate of every wire protocol a profile may configure, in the + * protocol table's order. A protocol absent here takes no configured compat, + * which is why an unrecognized `api` refuses every switch rather than + * silently dropping it. + */ +const COMPAT_GATES: Readonly>>> = { + 'openai-completions': COMPLETIONS_COMPAT_GATE, + 'openai-responses': RESPONSES_COMPAT_GATE, + 'anthropic-messages': ANTHROPIC_COMPAT_GATE, +} + +/** The field names one gate offers. */ +type OfferedIn = { [K in keyof G]: G[K] extends 'offer' ? K : never }[keyof G] + +/** Every compat field name a profile may set, on whichever protocol takes it. */ +type OfferedCompatField = + | OfferedIn + | OfferedIn + | OfferedIn + +/** + * pi-ai wire-compatibility switches, set on the route (its models' default) or + * per model (winning over the route, field by field). + * + * pi-ai decides each of these from the provider id and baseURL when no layer + * sets it, and a private gateway's URL says nothing: for an endpoint it does + * not recognize the detection answers as though it were OpenAI itself, which + * is wrong for most OpenAI-compatible gateways. So every field here is one a + * deployment must be able to state because nothing can infer it, while the + * fields pi-ai's catalog sets for a named vendor stay withheld. + * + * A field belongs to the protocols whose upstream compat type declares it: a + * model-level switch its protocol does not take fails resolution, and a + * route-level one skips past models it cannot fit. */ export interface PiAiCompatProfile { - /** Reasoning parameter format the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ - thinkingFormat?: PiAiThinkingFormat - /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */ + /** Whether the endpoint accepts `store`; `openai-completions`. */ + supportsStore?: boolean + /** + * Whether the endpoint accepts the `developer` role for the system prompt, + * which pi-ai sends only to a reasoning model; `false` keeps `system`. + * `openai-completions`, `openai-responses`. + */ + supportsDeveloperRole?: boolean + /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */ supportsReasoningEffort?: boolean + /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ + supportsUsageInStreaming?: boolean + /** Which output-cap field the endpoint reads; `openai-completions`. */ + maxTokensField?: 'max_completion_tokens' | 'max_tokens' + /** Whether tool results must carry `name`; `openai-completions`. */ + requiresToolResultName?: boolean + /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ + requiresAssistantAfterToolResult?: boolean + /** Whether thinking blocks must travel as text in `` delimiters; `openai-completions`. */ + requiresThinkingAsText?: boolean + /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */ + requiresReasoningContentOnAssistantMessages?: boolean + /** Reasoning parameter format the endpoint expects; `openai-completions`. */ + thinkingFormat?: PiAiThinkingFormat + /** Kwargs sent as `chat_template_kwargs`, for the two `chat-template` thinking formats; `openai-completions`. */ + chatTemplateKwargs?: Record + /** Whether the endpoint accepts `strict` in tool definitions; `openai-completions`, `openai-responses`. */ + supportsStrictMode?: boolean + /** Prompt-cache marker convention; `openai-completions`. */ + cacheControlFormat?: 'anthropic' + /** Whether the endpoint accepts long prompt-cache retention; all three protocols. */ + supportsLongCacheRetention?: boolean + /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ + supportsEagerToolInputStreaming?: boolean + /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */ + supportsCacheControlOnTools?: boolean + /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */ + supportsTemperature?: boolean + /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */ + forceAdaptiveThinking?: boolean + /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */ + allowEmptySignature?: boolean + /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */ + supportsStrictTools?: boolean +} + +/** Compile-time constraint that `T` is `never`. */ +type AssertNever = T + +/** + * Proof that every documented field is one a gate offers. A field the profile + * declares past the gates fails compilation with its own name in the error. + */ +export type EveryProfileFieldIsOffered = AssertNever> + +/** + * Proof that every offered field is documented. A gate entry flipped to + * `offer` without a profile field fails compilation with its own name in the + * error, which is the half a schema alone cannot catch. + */ +export type EveryOfferedFieldIsDocumented = AssertNever> + +/** + * The compat entries a profile actually set. + * + * schemastery materializes an absent dict as `{}` — the behavior + * `reasoningEfforts` works around with a union — so every parsed profile + * carries a `chatTemplateKwargs` key whether or not anyone wrote one. An empty + * one states nothing here: it would send no kwargs, which is exactly what + * leaving the field out does, so absent and empty are the same request and + * neither may make a route look like it configured a switch. + * @param compat - the configured switches, when any. + * @returns the entries carrying a value, in declaration order. + */ +function configuredCompatEntries(compat: PiAiCompatProfile | undefined): readonly (readonly [string, unknown])[] { + return Object.entries(compat ?? {}).flatMap(([field, value]) => { + if (value === undefined) return [] + const empty = typeof value === 'object' && value !== null && !Array.isArray(value) + && Object.keys(value as object).length === 0 + return empty ? [] : [[field, value] as const] + }) +} + +/** + * The protocols offering one compat field, in {@link COMPAT_GATES} order. + * @param field - configured compat field name. + * @returns the protocols whose compat takes it; empty when none does, which + * is either a withheld field or a name no upstream compat type declares. + */ +function compatProtocols(field: string): readonly string[] { + return Object.entries(COMPAT_GATES).flatMap(([api, gate]) => gate[field] === 'offer' ? [api] : []) +} + +/** + * The compat fields one protocol offers, for a diagnostic that has to show + * what was available instead of the name that missed. + * @param api - wire protocol. + * @returns the offered field names, or an empty list for a protocol taking no compat. + */ +function offeredCompatFields(api: string): readonly string[] { + return Object.entries(COMPAT_GATES[api] ?? {}).flatMap(([field, disposition]) => disposition === 'offer' ? [field] : []) +} + +/** + * Every offered field name, deduplicated, for the one diagnostic that cannot + * narrow by protocol: the vocabulary check runs before any protocol resolves, + * which is what lets it refuse a misspelling on a route whose models would + * never have reached the protocol that declares the intended field. + * @returns the offered field names across every protocol, in gate order. + */ +function allOfferedCompatFields(): readonly string[] { + const fields = new Set() + for (const api of Object.keys(COMPAT_GATES)) { + for (const field of offeredCompatFields(api)) fields.add(field) + } + return [...fields] +} + +/** + * Reject a compat key no protocol offers. Runs before any protocol is + * resolved, so a withheld field or a misspelling fails even on a route whose + * models never reach the protocol that would have taken it — the alternative + * being the silent drop that let an unreadable switch look applied. + * @param provider - provider route key, for diagnostics. + * @param site - the configuration site, for diagnostics. + * @param compat - the configured switches, when any. + * @throws Error naming the offending key. + */ +function assertOfferedCompatFields( + provider: string, + site: string, + compat: PiAiCompatProfile | undefined, +): void { + // Every key, not only the ones carrying a value: a withheld or undeclared + // name is never in the schema, so schemastery cannot have materialized it — + // whatever its value, a person wrote it and expects it to do something. + for (const field of Object.keys(compat ?? {})) { + if (compatProtocols(field).length > 0) continue + const declared = Object.values(COMPAT_GATES).some(gate => gate[field] !== undefined) + if (declared) { + invalid(provider, `${site} sets compat "${field}", which is not configurable here: pi-ai's installed` + + ' catalog sets it for the vendors that need it, so name that provider as the route instead') + } + invalid(provider, `${site} sets compat "${field}", which no wire protocol declares; the configurable` + + ` switches are ${allOfferedCompatFields().join(', ')}`) + } } /** One configured model entry: an id plus the catalog fields it overrides. */ @@ -233,7 +463,7 @@ export interface PiAiModelProfile { * declares the offered levels and their wire spellings. */ reasoningEfforts?: false | PiAiReasoningEfforts - /** Reasoning-dispatch switches for this model, winning over the route's. */ + /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */ compat?: PiAiCompatProfile } @@ -258,7 +488,7 @@ export interface RouteCatalogRequest { models?: readonly PiAiModelProfile[] /** Installed-catalog customizations by model id; only meaningful while `models` is absent. */ modelOverrides?: Readonly> - /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */ + /** Route-level wire-compatibility switches, landing on each model whose protocol declares them; entries override per field. */ compat?: PiAiCompatProfile /** Context capacity for a model neither the entry nor the catalog sizes. */ defaultContextWindow: number @@ -368,16 +598,20 @@ function resolveModelReasoning( return { reasoning: true, thinkingLevelMap: map } } +/** The compat block a materialized model carries, whichever protocol it speaks. */ +type ModelCompat = OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat + /** - * Resolve one model's compat block from the profile's reasoning switches. + * Resolve one model's compat block from the profile's switches. * - * A model switch wins over the route switch; whatever neither sets keeps the - * installed entry's value, and a field no layer decides falls through to - * pi-ai's baseURL-derived detection. Only an `openai-completions` model takes - * the switches at all: a model-level switch on any other protocol fails - * resolution, while a route-level default skips past such models — the same - * posture as the route-level `reasoning` default, which also must not fail - * models it does not fit. + * A model switch wins over the route switch field by field; whatever neither + * sets keeps the installed entry's value, and a field no layer decides falls + * through to pi-ai's own detection. A model-level switch its protocol does not + * take fails resolution — about one named model it can only be a mistake — + * while a route-level one skips past such models, since a route default must + * stay settable on a route whose models do not all speak one protocol. Every + * field reaching here is offered by some protocol; {@link + * assertOfferedCompatFields} has already refused the rest. * @param provider - provider route key, for diagnostics. * @param entry - the configured model entry. * @param route - the route-level switches, when any. @@ -391,31 +625,31 @@ function resolveModelCompat( route: PiAiCompatProfile | undefined, base: Model | undefined, api: string, -): { compat: OpenAICompletionsCompat } | Record { - const thinkingFormat = entry.compat?.thinkingFormat ?? route?.thinkingFormat - const supportsReasoningEffort = entry.compat?.supportsReasoningEffort ?? route?.supportsReasoningEffort - if (thinkingFormat === undefined && supportsReasoningEffort === undefined) return {} - if (api !== 'openai-completions') { - if (entry.compat?.thinkingFormat !== undefined || entry.compat?.supportsReasoningEffort !== undefined) { - invalid(provider, `model "${entry.id}" sets compat reasoning switches, but its api is "${api}";` - + ' thinkingFormat and supportsReasoningEffort exist only on openai-completions') - } - return {} +): { compat: ModelCompat } | Record { + const gate = COMPAT_GATES[api] + const configured: Record = {} + for (const [field, value] of configuredCompatEntries(route)) { + if (gate?.[field] !== 'offer') continue + configured[field] = value } + for (const [field, value] of configuredCompatEntries(entry.compat)) { + if (gate?.[field] !== 'offer') { + const offered = offeredCompatFields(api) + invalid(provider, `model "${entry.id}" sets compat "${field}", but its api is "${api}", which does not` + + ` take it; that switch exists on ${compatProtocols(field).join(', ')}, and "${api}" offers` + + ` ${offered.length === 0 ? 'no configurable compat' : offered.join(', ')}`) + } + configured[field] = value + } + if (Object.keys(configured).length === 0) return {} // The installed entry's compat matches the entry's OWN api — a route-level // `api` repoint (an anthropic catalog served through an OpenAI-compatible // gateway) leaves `base.compat` in the other protocol's shape, so it is // inherited only while the resolved api still is the entry's. A repointed // model starts from pi-ai's baseURL-derived detection instead, which is // what a protocol change means for every other compat field too. - const inherited: OpenAICompletionsCompat | undefined = base?.api === api ? base.compat : undefined - return { - compat: { - ...inherited, - ...thinkingFormat === undefined ? {} : { thinkingFormat }, - ...supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort }, - }, - } + const inherited = base?.api === api ? base.compat : undefined + return { compat: { ...inherited, ...configured } as ModelCompat } } /** One route's materialized catalog, plus the request caps its profile chose. */ @@ -485,8 +719,13 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { + ' must be listed in configuration') } const routeApi = sharedCatalogApi(defaults) - const routeCompatDefined = request.compat?.thinkingFormat !== undefined - || request.compat?.supportsReasoningEffort !== undefined + // Vocabulary before protocols: a withheld or undeclared switch is refused + // wherever it is written, so it cannot look applied on a route whose models + // never reach the protocol that would have taken it. + assertOfferedCompatFields(provider, 'route', request.compat) + for (const entry of entries) { + assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat) + } const seen = new Set() const configuredMaxTokens = new Map() const models = entries.map((entry) => { @@ -538,9 +777,15 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { ...resolveModelCompat(provider, entry, request.compat, base, api), } }) - if (routeCompatDefined && !models.some(model => model.api === 'openai-completions')) { - invalid(provider, 'sets compat reasoning switches, but no model on the route speaks openai-completions;' - + ' thinkingFormat and supportsReasoningEffort exist only on that protocol') + // Per field, not per block: a route may default a switch its completions + // models take beside one only its anthropic models do, and neither should + // fail for the other's sake. What is refused is a route default no model on + // the route could ever read, which is a route that will not behave as written. + for (const [field] of configuredCompatEntries(request.compat)) { + const takers = compatProtocols(field) + if (models.some(model => takers.includes(model.api))) continue + invalid(provider, `sets compat "${field}", but no model on the route speaks a protocol that takes it;` + + ` it exists on ${takers.join(', ')}`) } return { models, configuredMaxTokens } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 4e8e032df0..e5443b6a8f 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -14,7 +14,7 @@ * @module dsh-llm-pi-ai/config */ -import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' +import type { CacheRetention, ChatTemplateKwargValue, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' import z from '@deepseek-ai/schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' @@ -91,10 +91,11 @@ export interface PiAiProviderProfile { */ modelOverrides?: Record /** - * Reasoning-dispatch switches for every `openai-completions` model on this - * route; each model's own `compat` overrides per field. What neither sets - * keeps the installed catalog entry's value, then pi-ai's baseURL-derived - * detection. + * pi-ai wire-compatibility switches defaulting every model on this route + * whose protocol declares them; each model's own `compat` overrides per + * field. What neither sets keeps the installed catalog entry's value, then + * pi-ai's own detection. A switch no model on the route could read is + * refused rather than left looking applied. */ compat?: PiAiCompatProfile /** @@ -185,9 +186,43 @@ const thinkingBudgets = z.object({ high: z.number(), }) +/** + * One `chat_template_kwargs` value. The `$var` member is pi-ai's placeholder + * for a value dispatch fills from the request's thinking state, which is what + * makes a chat-template gateway configurable without restating its template. + */ +const chatTemplateKwarg: z = z.union([ + z.string(), + z.number(), + z.boolean(), + z.const(null), + z.object({ + $var: z.union(['thinking.enabled', 'thinking.effort'] as const).required(), + omitWhenOff: z.boolean(), + }), +]) + const compatProfile: z = z.object({ - thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + supportsStore: z.boolean(), + supportsDeveloperRole: z.boolean(), supportsReasoningEffort: z.boolean(), + supportsUsageInStreaming: z.boolean(), + maxTokensField: z.union(['max_completion_tokens', 'max_tokens'] as const), + requiresToolResultName: z.boolean(), + requiresAssistantAfterToolResult: z.boolean(), + requiresThinkingAsText: z.boolean(), + requiresReasoningContentOnAssistantMessages: z.boolean(), + thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), + chatTemplateKwargs: z.dict(chatTemplateKwarg), + supportsStrictMode: z.boolean(), + cacheControlFormat: z.union(['anthropic'] as const), + supportsLongCacheRetention: z.boolean(), + supportsEagerToolInputStreaming: z.boolean(), + supportsCacheControlOnTools: z.boolean(), + supportsTemperature: z.boolean(), + forceAdaptiveThinking: z.boolean(), + allowEmptySignature: z.boolean(), + supportsStrictTools: z.boolean(), }) /** diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 51ce347e9a..5e02646f64 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -596,6 +596,46 @@ describe('provider profile lifecycle', () => { expect(server.requests[1]).not.toHaveProperty('reasoning_effort') }) + it('keeps the system role on a declared route whose gateway rejects the developer one', async () => { + vi.stubEnv('PI_TEST_KEY', 'test-key') + const server = await mockServer([{ events: textEvents }, { events: textEvents }]) + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmPiAi, { + providers: { + 'acme-gateway': { + apiKeyEnv: 'PI_TEST_KEY', + api: 'openai-completions', + baseURL: `${server.url}/v1`, + models: [ + // pi-ai sends the system prompt as `developer` to a reasoning + // model whenever its URL detection says the endpoint is OpenAI — + // which is what an unrecognized private URL resolves to. Most + // OpenAI-compatible gateways reject that role. + { id: 'acme-think', reasoningEfforts: { off: null, high: 'high' }, compat: { supportsDeveloperRole: false } }, + { id: 'acme-guess', reasoningEfforts: { off: null, high: 'high' } }, + ], + }, + }, + }) + const roles = async (model: string): Promise => { + await assemble(ctx, { + provider: 'acme-gateway', + model, + reasoningEffort: ReasoningEffortId('high'), + system: 'you are a harness', + messages: [], + }) + const request = server.requests.at(-1) as { messages: { role: string }[] } + return request.messages.map(message => message.role) + } + + expect(await roles('acme-think')).toEqual(['system']) + // The switch is the only thing that changes it: the same route, same + // endpoint, same reasoning declaration still gets pi-ai's guess. + expect(await roles('acme-guess')).toEqual(['developer']) + }) + it('sends a declared off value as the effort parameter instead of omitting it', async () => { vi.stubEnv('PI_TEST_KEY', 'test-key') const server = await mockServer([{ events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index eb4ba511d4..f6f80eb878 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -755,7 +755,7 @@ describe('modelOverrides', () => { }) }) -describe('reasoning-dispatch compat switches', () => { +describe('compat switches', () => { /** The materialized models of one route, keyed by id. */ function modelsOf(providers: Record, route: string): Map> { const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? [] @@ -813,18 +813,133 @@ describe('reasoning-dispatch compat switches', () => { expect(models.get(responses.id)?.compat).toEqual(responses.compat) }) - it('rejects a model-level switch on a protocol that has no such field', () => { + it('rejects a model-level switch on a protocol that has no such field, naming what it offers', () => { expect(() => resolveProfiles({ anthropic: { models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }], }, - })).toThrow(/exist only on openai-completions/) + })).toThrow(/its api is "anthropic-messages", which does not take it.*exists on openai-completions/s) }) it('rejects route switches no model on the route can take', () => { expect(() => resolveProfiles({ anthropic: { compat: { thinkingFormat: 'openai' } }, - })).toThrow(/no model on the route speaks openai-completions/) + })).toThrow(/no model on the route speaks a protocol that takes it/) + }) + + it('carries the developer-role switch onto a hand-declared reasoning model', () => { + // pi-ai reads this switch only for a reasoning model, and detects it from + // the endpoint URL — which for a private gateway answers as though it were + // OpenAI itself, so the route must be able to say otherwise. + const models = modelsOf({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: false, maxTokensField: 'max_tokens' }, + models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }], + }, + }, 'acme-gateway') + + expect(models.get('acme-think')?.compat).toEqual({ + supportsDeveloperRole: false, + maxTokensField: 'max_tokens', + }) + }) + + it('carries a switch both OpenAI protocols declare onto an openai-responses route', () => { + const models = modelsOf({ + 'acme-responses': { + api: 'openai-responses', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: false }, + models: [{ id: 'acme-r', reasoningEfforts: { off: null, high: 'high' } }], + }, + }, 'acme-responses') + + expect(models.get('acme-r')?.compat).toEqual({ supportsDeveloperRole: false }) + }) + + it('carries an anthropic-only switch onto an anthropic-messages route', () => { + const models = modelsOf({ + 'acme-claude': { + api: 'anthropic-messages', + baseURL: 'https://acme.test', + compat: { supportsTemperature: false, supportsCacheControlOnTools: false }, + models: [{ id: 'acme-opus' }], + }, + }, 'acme-claude') + + expect(models.get('acme-opus')?.compat).toEqual({ + supportsTemperature: false, + supportsCacheControlOnTools: false, + }) + }) + + it('lands each route switch only on the models whose protocol declares it', () => { + const catalog = getBuiltinModels('xai') as readonly Model[] + const completions = catalog.find(model => model.api === 'openai-completions') + const responses = catalog.find(model => model.api === 'openai-responses') + if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog') + + const models = modelsOf({ + xai: { + // Both protocols take the first switch; only completions takes the second. + compat: { supportsDeveloperRole: false, thinkingFormat: 'openai' }, + models: [{ id: completions.id }, { id: responses.id }], + }, + }, 'xai') + + const onCompletions = models.get(completions.id)?.compat as OpenAICompletionsCompat + expect(onCompletions.supportsDeveloperRole).toBe(false) + expect(onCompletions.thinkingFormat).toBe('openai') + const onResponses = models.get(responses.id)?.compat as { supportsDeveloperRole?: boolean; thinkingFormat?: string } + expect(onResponses.supportsDeveloperRole).toBe(false) + expect(onResponses.thinkingFormat).toBeUndefined() + }) + + it('carries chat-template kwargs beside the thinking format that dispatches through them', () => { + const models = modelsOf({ + 'acme-qwen': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ + id: 'qwen-local', + reasoningEfforts: { off: null, medium: 'medium' }, + compat: { + thinkingFormat: 'qwen-chat-template', + chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } }, + }, + }], + }, + }, 'acme-qwen') + + expect(models.get('qwen-local')?.compat).toEqual({ + thinkingFormat: 'qwen-chat-template', + chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } }, + }) + }) + + it('refuses a compat key no wire protocol declares instead of dropping it', () => { + // The silent drop is what let an unreadable switch look applied: schemastery + // passes unknown keys through, and resolution used to read only two fields. + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDevelperRole: false } as never, + models: [{ id: 'acme-a' }], + }, + })).toThrow(/compat "supportsDevelperRole", which no wire protocol declares; the configurable switches are .*\bsupportsDeveloperRole\b/) + }) + + it('refuses a compat key pi-ai’s catalog owns, pointing at the catalog route', () => { + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + models: [{ id: 'acme-a', compat: { openRouterRouting: {} } as never }], + }, + })).toThrow(/compat "openRouterRouting", which is not configurable here/) }) }) From 85e821b9260555db05100ce13bb34e8f7869ddab Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 11:28:58 +0800 Subject: [PATCH 066/137] fix(ci): remove flaky subprocess readiness handshake --- .../subprocess-local/tests/fixtures/process-exit-host.ts | 2 -- .../subprocess/subprocess-local/tests/process-exit.spec.ts | 4 ---- 2 files changed, 6 deletions(-) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts index e59289be09..721c5134c3 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -13,7 +13,6 @@ if ((kind !== 'ordinary' && kind !== 'terminal') } const treeState = join(root, 'tree.json') -const ready = join(root, 'ready') const proceed = join(root, 'proceed') const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url)) @@ -58,7 +57,6 @@ const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unkn if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) { throw new Error('managed tree published invalid process ids') } -await writeFile(ready, 'ready') await waitForFile(proceed) if (trigger === 'dispose') { diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index 217338fa1e..cdfc4f4ae0 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -107,10 +107,6 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { let treeGone = false try { state = await readTree(join(root, 'tree.json')) - await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), { - interval: 10, - timeout: scenarioTimeoutMs, - }) if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) await writeFile(join(root, 'proceed'), 'proceed') const outcome = await child From 94f6fd1306e4eb7da1a22d7e2b68b61871505504 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 13:50:53 +0800 Subject: [PATCH 067/137] Support MCP in packaged Python runtime --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 + ...-executable-sdk-runtime-distribution.zh.md | 2 + .../workflows/build-exe-for-python-sdk.yml | 3 + .github/workflows/python-release.yml | 1 + .gitlab-ci.yml | 3 +- pnpm-lock.yaml | 3 + python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 2 + python/sdk-runtime/README.zh.md | 2 + python/sdk-runtime/package.json | 1 + python/sdk/tests/test_smoke_model.py | 41 ++++ scripts/smoke-python-runtime.py | 191 +++++++++++++++++- 13 files changed, 253 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 37e39c7dbf..7594f17605 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 3715aa5b1148eb97faa04d5ac5778cceb47b97fc -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: a9632deb847d0aa24f376edb017bd70c4ff81302 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: a09c1438d61c3ad6b92d694360a1591764f3d67e +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ce6568d7303433a5a4ca57ac76f67051944ed997 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 3715aa5b11..a09c1438d6 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -38,6 +38,8 @@ Inside the exe's VFS sits a **real package tree in build-artifact form** (each p The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. + ### Build pipeline and artifacts [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index a9632deb84..ce6568d730 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -38,6 +38,8 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都显式列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 + ### 构建流水线与产物 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 8c6569fa07..5fc6de459d 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -272,6 +272,8 @@ jobs: deepseek-harness-sdk=="$VERSION" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ --scenario sdk-default + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ + --scenario sdk-mcp - name: Check Linux GLIBC requirements if: runner.os == 'Linux' @@ -309,6 +311,7 @@ jobs: /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default + /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp ' - uses: actions/upload-artifact@v7 diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 4a2f05b10b..a395a8e8bd 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -72,6 +72,7 @@ jobs: run: | python -m pip install --find-links dist "deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}" python scripts/smoke-python-runtime.py --scenario sdk-default + python scripts/smoke-python-runtime.py --scenario sdk-mcp validate: name: Validate release candidate diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 87c4e8dccb..cf5a093f69 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -45,6 +45,7 @@ sdk-wheel: - python -m venv .wheel-smoke - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_WHEEL_VERSION" - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default + - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-mcp - | if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then readelf --version-info "$EXE" > glibc-versions.txt @@ -56,7 +57,7 @@ sdk-wheel: linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; esac - docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_WHEEL_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_WHEEL_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp" fi - | if [ "$PLATFORM" = macos-arm64 ]; then diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f36dbcf78..da4fb969dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8460,6 +8460,9 @@ importers: '@deepseek-ai/dsh-llm-retry': specifier: workspace:^ version: link:../../packages/llm/llm-retry + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../packages/mcp/mcp-client '@deepseek-ai/dsh-output-retention': specifier: workspace:^ version: link:../../packages/util/output-retention diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 1d4b9fd739..d04296dd33 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-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 python/sdk-runtime/README.md -README.md: 592ce091f3b6c4bc151dfdee9d313b7c970c95de -README.zh.md: 0a29281a39c885bc3c40e017bdc9475e0065605f +README.md: c00357dbad74f8de705789bce8e4a55cc5fd67b1 +README.zh.md: a62edb4cd98338d22332f8e124c3ecb3f2356ace diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 592ce091f3..c00357dbad 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -13,6 +13,8 @@ Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injecte Both carriers hold the same content, defined once: the [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. +The bundled plugin set includes `@deepseek-ai/dsh-mcp-client`, so an external Cordis config can connect to stdio or Streamable HTTP MCP servers and expose their tools to the model. The wheel does not bundle MCP server programs or credentials: a stdio config supplies its executable and arguments, while a Streamable HTTP config supplies its URL and headers. The bridge supports MCP tools; MCP Resources and Prompts remain unsupported. + A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 0a29281a39..a62edb4cd9 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -13,6 +13,8 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 +内置插件集合包含 `@deepseek-ai/dsh-mcp-client`,因此外部 Cordis 配置可以连接 stdio 或 Streamable HTTP MCP server,并向模型提供这些 server 的工具。wheel 包不包含 MCP server 程序或凭据:stdio 配置需要提供可执行程序及其参数,Streamable HTTP 配置需要提供 URL 和请求头。该桥接仅支持 MCP 工具,尚不支持 MCP Resources 与 Prompts。 + exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 083a8a87d7..cd43257014 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", diff --git a/python/sdk/tests/test_smoke_model.py b/python/sdk/tests/test_smoke_model.py index d4be72032d..c0e6b9f70e 100644 --- a/python/sdk/tests/test_smoke_model.py +++ b/python/sdk/tests/test_smoke_model.py @@ -30,3 +30,44 @@ def test_child_prompt_precedes_runtime_context(prompt_name: str, expected: str) for chunk in chunks for choice in chunk.get("choices", []) ) + + +def test_mcp_smoke_requests_the_discovered_tool() -> None: + chunks = SMOKE["completion_chunks"]({ + "messages": [{"role": "user", "content": SMOKE["MCP_PROMPT"]}], + "tools": [{"type": "function", "function": {"name": "mcp__fixture__add"}}], + }) + + calls = [ + call + for chunk in chunks + for choice in chunk.get("choices", []) + for call in choice.get("delta", {}).get("tool_calls", []) + ] + assert calls[0]["function"] == { + "name": "mcp__fixture__add", + "arguments": '{"a": 19, "b": 23}', + } + + +def test_mcp_smoke_accepts_the_external_server_result() -> None: + chunks = SMOKE["completion_chunks"]({ + "messages": [ + {"role": "user", "content": SMOKE["MCP_PROMPT"]}, + { + "role": "assistant", + "tool_calls": [{ + "id": "mcp-add", + "type": "function", + "function": {"name": "mcp__fixture__add", "arguments": '{}'}, + }], + }, + {"role": "tool", "tool_call_id": "mcp-add", "content": "42"}, + ], + }) + + assert any( + choice.get("delta", {}).get("content") == SMOKE["MCP_TEXT"] + for chunk in chunks + for choice in chunk.get("choices", []) + ) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index b517006475..76f3e4f60a 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -9,6 +9,7 @@ import json import os import queue import subprocess +import sys import tempfile import threading import time @@ -32,6 +33,8 @@ MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools." FS_SEARCH_TEXT = "filesystem search smoke ok" FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK" +MCP_PROMPT = "Exercise the packaged MCP client with one external stdio server." +MCP_TEXT = "MCP client smoke ok" MINIMAL_CORDIS = ( Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml" ) @@ -143,6 +146,130 @@ FS_SEARCH_CORDIS = """\ config: sampleOverCapGlobResults: false """ +MCP_SERVER_SCRIPT = """\ +import json +import os +import sys + + +log_path = os.environ.get("MCP_SMOKE_LOG") + + +def send(message): + sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\\n") + sys.stdout.flush() + + +for line in sys.stdin: + request = json.loads(line) + if log_path is not None: + with open(log_path, "a", encoding="utf-8") as log: + log.write(str(request.get("method")) + "\\n") + request_id = request.get("id") + if request_id is None: + continue + method = request.get("method") + if method == "initialize": + send({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": request["params"]["protocolVersion"], + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "python-wheel-fixture", "version": "1.0.0"}, + }, + }) + elif method == "tools/list": + send({ + "jsonrpc": "2.0", + "id": request_id, + "result": { + "tools": [{ + "name": "add", + "description": "Add two numbers.", + "inputSchema": { + "type": "object", + "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, + "required": ["a", "b"], + "additionalProperties": False, + }, + }], + }, + }) + elif method == "tools/call": + params = request["params"] + if params.get("name") != "add" or params.get("arguments") != {"a": 19, "b": 23}: + send({ + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32602, "message": "unexpected tool call"}, + }) + continue + send({ + "jsonrpc": "2.0", + "id": request_id, + "result": {"content": [{"type": "text", "text": "42"}]}, + }) + else: + send({ + "jsonrpc": "2.0", + "id": request_id, + "error": {"code": -32601, "message": f"unsupported method: {method}"}, + }) +""" + + +def mcp_cordis(server_script: Path) -> str: + """Build an external config that mounts the packaged MCP client.""" + return json.dumps([ + { + "id": "sdk-jsonrpc-server", + "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", + }, + { + "id": "agent-core", + "name": "@deepseek-ai/dsh-agent-spine-demo", + "config": { + "workspaceContext": False, + "skills": {"enabled": False}, + "toolBash": False, + }, + }, + { + "id": "sessions", + "name": "@deepseek-ai/dsh-session-persistence-jsonl", + "config": {"root": "./sessions", "compression": "none"}, + }, + { + "id": "mcp-fixture", + "name": "@deepseek-ai/dsh-mcp-client", + "config": { + "serverName": "fixture", + "transport": "stdio", + "command": sys.executable, + "args": [str(server_script)], + "env": {"MCP_SMOKE_LOG": str(server_script.with_suffix(".log"))}, + "failOnStartupError": True, + "reconnect": {"enabled": False}, + }, + }, + ], indent=2) + + +def wait_for_mcp_discovery(log_path: Path) -> None: + """Wait until the external server has answered initial tool discovery.""" + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if log_path.exists() and "tools/list" in log_path.read_text().splitlines(): + # The server records the request before flushing its response; give the + # client one scheduler interval to register the returned generation. + time.sleep(0.1) + return + time.sleep(0.025) + observed = log_path.read_text() if log_path.exists() else "" + raise AssertionError(f"packaged MCP client did not complete tool discovery: {observed}") + + class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -177,6 +304,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) + mcp = mcp_tool_followup(call_id, tool_name, tool_text) + if mcp is not None: + return mcp fs_search = fs_search_tool_followup(call_id, tool_name, tool_text) if fs_search is not None: return fs_search @@ -222,6 +352,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: CODE_PROMPT, WORKFLOW_PROMPT, FS_SEARCH_PROMPT, + MCP_PROMPT, } prompt = next( (candidate for candidate in user_prompts if candidate in scenario_prompts), @@ -271,9 +402,29 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: "grep", {"pattern": FS_SEARCH_MARKER, "path": "."}, ) + if prompt == MCP_PROMPT: + assert_advertised_tool(body, "mcp__fixture__add") + return tool_call_chunks( + "mcp-add", + "mcp__fixture__add", + {"a": 19, "b": 23}, + ) return text_chunks(EXPECTED_TEXT) +def mcp_tool_followup( + call_id: str, + tool_name: str, + tool_text: str, +) -> list[dict[str, object]] | None: + """Verify one tool call through the packaged MCP client.""" + if call_id != "mcp-add": + return None + if tool_name != "mcp__fixture__add" or "42" not in tool_text: + raise AssertionError(f"packaged MCP call returned an unexpected result: {tool_name}: {tool_text}") + return text_chunks(MCP_TEXT) + + def fs_search_tool_followup( call_id: str, tool_name: str, @@ -538,7 +689,7 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-mcp", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) @@ -563,6 +714,8 @@ def main() -> None: if args.scenario in {"all", "sdk-fs-search"}: assert args.exe is not None smoke_sdk_fs_search(model.url, args.exe.resolve()) + if args.scenario in {"all", "sdk-mcp"}: + smoke_sdk_mcp(model.url, None if args.exe is None else args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -686,6 +839,42 @@ def smoke_sdk_fs_search(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, FS_SEARCH_TEXT, FS_SEARCH_MARKER, "needle.txt") +def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None: + """Discover and call an external stdio MCP tool through the packaged client.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-mcp-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + server_script = root / "mcp_server.py" + server_script.write_text(MCP_SERVER_SCRIPT) + cordis = root / "cordis.yml" + cordis.write_text(mcp_cordis(server_script)) + discovery_log = server_script.with_suffix(".log") + with DeepSeekHarness( + provider="deepseek-official", + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=None if executable is None else str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + wait_for_mcp_discovery(discovery_log) + result = harness.run(MCP_PROMPT, session_id="mcp-smoke") + + assert result.final_response == MCP_TEXT, result.final_response + assert discovery_log.read_text().splitlines() == [ + "initialize", + "notifications/initialized", + "tools/list", + "tools/call", + ] + assert_session_log(sessions, root, MCP_TEXT, "mcp__fixture__add", "42") + + def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: """Drive and compare the advanced SDK/executable behavioral snapshot.""" from deepseek_harness import DeepSeekHarness From 91e8d62b2af83cdca87a9d83b53b7665736a5c81 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:30:47 +0800 Subject: [PATCH 068/137] fix(subprocess): detect exited Windows terminals --- .../subprocess-local/README.i18n.yaml | 4 +- .../subprocess/subprocess-local/README.md | 2 +- .../subprocess/subprocess-local/README.zh.md | 2 +- .../subprocess-local/src/windows-inspector.ts | 45 ++++++++++++++----- .../tests/windows-inspector.spec.ts | 14 ++++-- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/subprocess/subprocess-local/README.i18n.yaml b/packages/subprocess/subprocess-local/README.i18n.yaml index 7af31d7aff..6b02932a92 100644 --- a/packages/subprocess/subprocess-local/README.i18n.yaml +++ b/packages/subprocess/subprocess-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/subprocess/subprocess-local/README.md -README.md: 2c2046a886004321b4438da2af2723413e2454fe -README.zh.md: 26955caca951388a60b55c7afa806a933531e8e1 +README.md: 0935bb309bd10dec7503a74708a28442223bf296 +README.zh.md: e2e6c67e4dbe1890bcb5532594a62b650bfed85d diff --git a/packages/subprocess/subprocess-local/README.md b/packages/subprocess/subprocess-local/README.md index 2c2046a886..0935bb309b 100644 --- a/packages/subprocess/subprocess-local/README.md +++ b/packages/subprocess/subprocess-local/README.md @@ -11,7 +11,7 @@ Local Service Provider for the [`@deepseek-ai/dsh-subprocess`](../subprocess/REA - **Credential scrub + explicit merge** — `process.env` minus credential-shaped vars (`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names; the spec's explicit `env` merges after that scrub with no namespace validation, so a deliberately supplied credential or current `DSH_*` fact wins while stale nested-harness identity cannot leak in ambiently. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). - **Offset-based reads** — collect-mode readers return deltas in whole-stream byte coordinates; the service never holds a cursor, so consumer-owned cursors (the bash background read path) and full-stream re-reads coexist, before and after settlement. - **Executable lookup** — `resolveExecutable` checks absolute files or searches the scrubbed effective PATH with platform-aware executable extensions; relative paths containing separators are rejected at the seam, and relative PATH entries resolve from the host process cwd. -- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes start identities, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's absence through those identities because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. +- **Terminal-process ownership** — `spawnTerminal` allocates `node-pty`, bridges UTF-8 terminal text, inspects and signals the current foreground process group, and exposes one awaited termination operation that sweeps descendants before and after terminating the top-level shell. Each foreground inspection retains exact identities from the rooted tree; Linux also enumerates the POSIX session after its leader exits. A previously observed macOS descendant and any same-session Linux member therefore remain fenced after reparenting, while pid/start identity prevents cleanup from following PID reuse. On Windows the koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes start identities with zero-time process-handle waits for liveness, reports the shell pid as the pseudo foreground group (Windows has no POSIX groups), and teardown verifies the shell's termination because externally taskkilled shells may never fire node-pty's exit notification. The higher PTY backend owns prompt readiness, buffers, and model-facing operations. - **Terminate-and-join disposal** — the service retains live handles so its own disposal can escalate every running tree and await its exit; quiescent and spawn-failed handles leave the live set after whole-tree or terminal-session cleanup finishes. - **Synchronous host-exit finalization** — while the service effect is active, a Node `exit` listener force-terminates every ordinary tree and observable terminal session still in the same live sets. The local-only operations send POSIX SIGKILL to the managed group, run Windows `taskkill /T /F`, and synchronously signal captured/current terminal identities around the PTY root kill; they create no promise or timer, preserve the host's exit code and diagnostic, contain each target's failure, and do not claim quiescence. Normal disposal keeps the awaited graceful path above. See the [host-exit cleanup decision](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md). diff --git a/packages/subprocess/subprocess-local/README.zh.md b/packages/subprocess/subprocess-local/README.zh.md index 26955caca9..e2e6c67e4d 100644 --- a/packages/subprocess/subprocess-local/README.zh.md +++ b/packages/subprocess/subprocess-local/README.zh.md @@ -11,7 +11,7 @@ - **凭据清除 + 显式合并**:以 `process.env` 为基础,移除形似凭据的变量(`*KEY*`/`*PASSWORD*`/`*SECRET*`/`*TOKEN*`)和所有环境中已有的 `DSH_*` 名称;spec 的显式 `env` 在该清除之后合并且不做命名空间校验,因此有意提供的凭据或当前 `DSH_*` 事实会胜出,而陈旧的嵌套 harness 身份无法从环境中隐式漏入。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)与[受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 - **基于偏移量的读取**:收集模式的读取器按完整流的字节坐标返回增量;服务自身从不持有游标,因此消费方自有的游标(bash 的后台读取路径)与完整流重读可以共存,结算前后皆然。 - **可执行文件查找**:`resolveExecutable` 检查绝对文件,或根据平台可执行文件扩展名在清理后的有效 PATH 中搜索;含分隔符的相对路径在该 seam 处被拒绝,相对 PATH 条目从宿主进程 cwd 解析。 -- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 启动身份,把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组),拆卸则通过这些身份验证 shell 已消失——因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 +- **终端进程所有权**:`spawnTerminal` 分配 `node-pty`,桥接 UTF-8 终端文本,检查当前前台进程组并向其发送信号,还会公开一项须等待的终止操作,在终止顶层 shell 前后清理后代进程。每次前台检查都会保留根进程树中的精确身份;Linux 还会在 POSIX 会话 leader 退出后枚举该会话。因此,之前观察到的 macOS 后代以及同会话 Linux 成员在重新设定父进程后仍受围栏保护,pid/start 身份则防止清理跟随 PID 复用。在 Windows 上,基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 启动身份与进程句柄零时等待结合起来判断存活状态,并把 shell pid 作为伪前台进程组(Windows 没有 POSIX 进程组)。拆卸会验证 shell 已终止,因为被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知。上层 PTY 后端负责提示符就绪、缓冲区与面向模型的操作。 - **先终止再等待退出的 dispose(资源释放)**:服务保留存活句柄,使自身的 dispose 能对每个仍在运行的进程树执行升级并等待其退出;完全停稳与 spawn 失败的句柄会在整棵进程树或 terminal session 清理完成后离开存活集合。 - **同步宿主退出最终清理**:服务 effect 仍有效时,Node `exit` listener 会强制终止同一组存活集合中仍存在的每棵普通进程树和可观察 terminal session。这些仅供本地实现使用的操作会向受管 POSIX 进程组发送 SIGKILL、在 Windows 运行 `taskkill /T /F`,并在终止 PTY root 前后同步向已捕获及当前可观察的 terminal 身份发送信号;它们不会创建 Promise 或 timer,不改变宿主退出码与诊断,会分别包含每个目标的失败,也不会声称已经完全停稳。正常 dispose 仍使用上面的须等待温和路径。参见[宿主退出清理决策](../../../.agents/notes/implemented/bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md)。 diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 9c306f595f..7280cbb9ee 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -1,8 +1,9 @@ /** * Windows process-table operations for terminal readiness, signalling, and * teardown: Toolhelp32 snapshot enumeration with GetProcessTimes creation-time - * identity, the shell pid as a pseudo process group (Windows has no POSIX - * groups), and taskkill tree signalling. The koffi bindings load lazily so + * identity and process-handle wait-state liveness, the shell pid as a pseudo + * process group (Windows has no POSIX groups), and taskkill tree signalling. + * The koffi bindings load lazily so * non-Windows processes never touch Win32 libraries; all decision logic takes * an injectable internals boundary so suites can pin it on any host. * @module dsh-subprocess-local/windows-inspector @@ -19,12 +20,20 @@ export interface ProcessEntry { parentPid: number } +/** Creation identity plus the process object's current wait state. */ +export interface WindowsProcessState { + /** GetProcessTimes creation identity used to fence PID reuse. */ + started: string + /** Whether a zero-time process-handle wait reports the process still running. */ + active: boolean +} + /** Injectable Windows process operations used by one local PTY session. */ export interface WindowsProcessInspectorInternals { /** Enumerate the current process table (pid/parent pairs). */ snapshot(): ProcessEntry[] - /** Return one process's creation-time identity, or undefined when unreadable. */ - creationTime(pid: number): string | undefined + /** Return one process's creation identity and wait state, or undefined when unreadable. */ + processState(pid: number): WindowsProcessState | undefined /** Terminate one process tree; `force` maps to taskkill `/F`. */ taskkill(pid: number, force: boolean): void } @@ -89,7 +98,7 @@ export class WindowsProcessInspector implements ProcessInspector { } processTree(rootPid: number): ProcessIdentity[] { - return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.creationTime(pid)) + return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.processState(pid)?.started) } processSession(_sessionId: number): ProcessIdentity[] { @@ -97,8 +106,8 @@ export class WindowsProcessInspector implements ProcessInspector { } isAlive(identity: ProcessIdentity): boolean { - const started = this.internals.creationTime(identity.pid) - return started !== undefined && started === identity.started + const state = this.internals.processState(identity.pid) + return state?.active === true && state.started === identity.started } signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { @@ -158,6 +167,7 @@ interface Win32Bindings { kernel: NativePtr, user: NativePtr, ): number + waitForSingleObject(handle: NativePtr, milliseconds: number): number closeHandle(handle: NativePtr): number } @@ -202,6 +212,9 @@ let cachedStructs: ReturnType | undefined const TH32CS_SNAPPROCESS = 0x2 const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +const SYNCHRONIZE = 0x00100000 +const WAIT_OBJECT_0 = 0 +const WAIT_TIMEOUT = 0x102 let cachedBindings: Win32Bindings | undefined @@ -230,6 +243,7 @@ function win32Bindings(): Win32Bindings { koffi.pointer(FILETIME), koffi.pointer(FILETIME), ]), + waitForSingleObject: bind('WaitForSingleObject', 'uint32', [PVOID, 'uint32']), closeHandle: bind('CloseHandle', 'int', [PVOID]), } as unknown as Win32Bindings return cachedBindings @@ -273,10 +287,10 @@ function snapshotWindowsProcesses(bindings: Win32Bindings): ProcessEntry[] { return entries } -/** Read one process's creation-time identity through GetProcessTimes. */ -function windowsCreationTime(bindings: Win32Bindings, pid: number): string | undefined { +/** Read one process's creation identity and current wait state. */ +function windowsProcessState(bindings: Win32Bindings, pid: number): WindowsProcessState | undefined { const { FILETIME } = win32Structs() - const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) + const handle = bindings.openProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid) if (isInvalidHandle(handle)) return undefined try { const creation = allocNative(FILETIME, 1) @@ -288,7 +302,14 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und treats undefined as a detector miss. */ if (bindings.getProcessTimes(handle, creation, exit, kernel, user) === 0) return undefined const record = koffi.decode(creation, FILETIME) as { dwLowDateTime: number; dwHighDateTime: number } - return `${record.dwHighDateTime}:${record.dwLowDateTime}` + const wait = bindings.waitForSingleObject(handle, 0) + /* v8 ignore next -- an opened process handle has exactly one of these two + zero-time wait states; an unexpected Win32 failure is an unreadable process. */ + if (wait !== WAIT_OBJECT_0 && wait !== WAIT_TIMEOUT) return undefined + return { + started: `${record.dwHighDateTime}:${record.dwLowDateTime}`, + active: wait === WAIT_TIMEOUT, + } } finally { bindings.closeHandle(handle) } @@ -298,7 +319,7 @@ function windowsCreationTime(bindings: Win32Bindings, pid: number): string | und function defaultWindowsProcessInternals(): WindowsProcessInspectorInternals { return { snapshot: () => snapshotWindowsProcesses(win32Bindings()), - creationTime: pid => windowsCreationTime(win32Bindings(), pid), + processState: pid => windowsProcessState(win32Bindings(), pid), taskkill: taskkillTree, } } diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index 667c6cae46..e00bdeb9e2 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -9,21 +9,22 @@ import type { NativePtr, ProcessEntry, WindowsProcessInspectorInternals, + WindowsProcessState, } from '@deepseek-ai/dsh-subprocess-local/src/windows-inspector.ts' function fakeInternals() { const entries: ProcessEntry[] = [] - const times = new Map() + const states = new Map() const kills: Array<[number, boolean]> = [] return { internals: { snapshot: () => [...entries], - creationTime: pid => times.get(pid), + processState: pid => states.get(pid), taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) }, } satisfies WindowsProcessInspectorInternals, - add(entry: ProcessEntry, started?: string): void { + add(entry: ProcessEntry, started?: string, active = true): void { entries.push(entry) - if (started !== undefined) times.set(entry.pid, started) + if (started !== undefined) states.set(entry.pid, { started, active }) }, kills, } @@ -80,6 +81,9 @@ describe('WindowsProcessInspector (injected internals)', () => { expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true) expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false) expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false) + + fake.add({ pid: 12, parentPid: 10 }, 't12', false) + expect(inspector.isAlive({ pid: 12, started: 't12' })).toBe(false) }) it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => { @@ -94,8 +98,10 @@ describe('WindowsProcessInspector (injected internals)', () => { it('signals a process only while its start identity matches', () => { const fake = fakeInternals() fake.add({ pid: 10, parentPid: 0 }, 't10') + fake.add({ pid: 11, parentPid: 10 }, 't11', false) const inspector = new WindowsProcessInspector(fake.internals) inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL') + inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL') inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM') expect(fake.kills).toEqual([[10, true]]) }) From 2f759a6b6509c3e56be761692068ea62b0372fe2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:30:59 +0800 Subject: [PATCH 069/137] fix(shell): preserve prompt-like PowerShell output --- packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh-persistent/src/index.ts | 2 +- .../shell/tool-pwsh-persistent/tests/tools.spec.ts | 14 +++++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 84278ef05e..24384c240c 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.0-rc.6", + "version": "0.1.0-rc.7", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/src/index.ts b/packages/shell/tool-pwsh-persistent/src/index.ts index 5fc2d3a03d..f0a575363e 100644 --- a/packages/shell/tool-pwsh-persistent/src/index.ts +++ b/packages/shell/tool-pwsh-persistent/src/index.ts @@ -124,7 +124,7 @@ function commandOutput( // scrolled out and extraction fell back to the echoed copy. captured = captured.replaceAll(wrapper, '') return { - text: stripPrompt(captured.replace(/^\r?\n/, '')), + text: captured.replace(/^\r?\n/, '').replace(/\r?\n$/, ''), incomplete: startMarker < 0, exitCode: Number(status), } diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index 6a856b1ff8..8d76abd3a6 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -100,6 +100,7 @@ type StubMode = | 'paged-scrollback' | 'with-echo' | 'exit-after-send' + | 'prompt-collision' const START_PATTERN = /__DSH_PERSISTENT_PWSH_START_[^_]+(?:-[^_]+)*__/ const END_PATTERN = /__DSH_PERSISTENT_PWSH_END_[^:]+:/ @@ -215,7 +216,9 @@ class StubTerminalSession implements TerminalBackendSession { } const commandOutput = this.mode === 'large' ? 'x'.repeat(100) - : this.mode === 'nonzero' ? '' : 'hello from stub' + : this.mode === 'nonzero' ? '' + : this.mode === 'prompt-collision' ? this.motd + : 'hello from stub' const exitCode = this.mode === 'nonzero' ? 7 : 0 const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}` this.scrollback += output @@ -362,6 +365,15 @@ describe('tool-pwsh-persistent', () => { expect(result).not.toContain('Invoke-Expression') }) + it('preserves command output that equals the private shell prompt', async () => { + const { ctx, owner, stub } = await setup({ backendType: 'stub' }) + await call(ctx, owner, 'warm up') + const session = stub.sessions[0]! + + session.mode = 'prompt-collision' + expect(text(await call(ctx, owner, 'complete prompt collision'))).toBe(session.motd) + }) + it('reports the exit path when the shell exits between send settlement and the next poll', async () => { const { ctx, owner, stub } = await setup({ backendType: 'stub' }) await call(ctx, owner, 'warm up') From cef99b17d47fdda90921da698cdc8d35e497e2b3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:31:22 +0800 Subject: [PATCH 070/137] test(acp): snapshot persistent PowerShell tool --- examples/acp-agent/tests/acp.snapshot.ts | 10 +++++ .../tests/persistent-pwsh.cordis.snapshot.yml | 45 +++++++++++++++++++ .../tests/persistent-pwsh.cordis.yml | 42 +++++++++++++++++ .../persistent-pwsh-tool-turn/input.json | 7 +++ .../persistent-pwsh-tool-turn/session.jsonl | 34 ++++++++++++++ .../stdout.expected.jsonl | 4 ++ .../system-prompt.expected.md | 3 ++ .../tool-schemas.expected.json | 21 +++++++++ examples/package.json | 1 + pnpm-lock.yaml | 3 ++ 10 files changed, 170 insertions(+) create mode 100644 examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml create mode 100644 examples/acp-agent/tests/persistent-pwsh.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index c5bf81fc1f..b784529d4e 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -61,6 +61,7 @@ const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const PERSISTENT_PWSH_CONFIG = fileURLToPath(new URL('./persistent-pwsh.cordis.yml', import.meta.url)) const BACKGROUND_TASK_ADMISSION_CONFIG = fileURLToPath( new URL('../background-job-admission.cordis.yml', import.meta.url), ) @@ -264,6 +265,15 @@ const SCENARIOS: Scenario[] = [ // newline and one recording replays on every host. pwshOnly: true, }, + { + name: 'persistent-pwsh-tool-turn', + hasModelTurn: true, + recorded: true, + pinsHeader: true, + headerClass: 'persistent-pwsh', + configPath: PERSISTENT_PWSH_CONFIG, + pwshOnly: true, + }, // Authored keyless replay through a test-only partial-Landlock provider: // the exact compatibility notice must stay ordinary stderr when the wrapped // `false` command exits 1, rather than becoming SANDBOX_UNAVAILABLE. diff --git a/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml b/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml new file mode 100644 index 0000000000..7b90b2298b --- /dev/null +++ b/examples/acp-agent/tests/persistent-pwsh.cordis.snapshot.yml @@ -0,0 +1,45 @@ +# Keyless replay counterpart to persistent-pwsh.cordis.yml. +- id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-pro + +- id: terminal + name: '@deepseek-ai/dsh-terminal' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + config: + shellDialect: pwsh + timeoutMs: 30000 + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh-persistent + name: '@deepseek-ai/dsh-tool-pwsh-persistent' diff --git a/examples/acp-agent/tests/persistent-pwsh.cordis.yml b/examples/acp-agent/tests/persistent-pwsh.cordis.yml new file mode 100644 index 0000000000..0b3cd18c70 --- /dev/null +++ b/examples/acp-agent/tests/persistent-pwsh.cordis.yml @@ -0,0 +1,42 @@ +# Minimal live counterpart for the persistent-pwsh-tool-turn snapshot composition. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + models: + - id: deepseek-v4-pro + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: terminal + name: '@deepseek-ai/dsh-terminal' + +- id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + config: + shellDialect: pwsh + timeoutMs: 30000 + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-pro + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + goals: false + persona: You are a concise snapshot agent working in {{cwd}}. + +- id: tool-pwsh-persistent + name: '@deepseek-ai/dsh-tool-pwsh-persistent' diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json new file mode 100644 index 0000000000..653e9a346c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl new file mode 100644 index 0000000000..220bdd516e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/session.jsonl @@ -0,0 +1,34 @@ +{"type":"session","version":0,"id":"0b7ff6ab-2486-4b2f-a43e-0fa29a1a46ed","createdAt":1785678162241,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785898456879,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"}]}} +{"type":"turn/start","seq":1,"time":1785898456880,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785898456880,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1785678162261,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785898456903,"data":{"content":[{"type":"text","text":"Use the pwsh tool to run exactly: [Console]::Out.Write('PWSH_OK'). Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"b4ce4fdc-a87a-41d0-b418-80a0fb235abb"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":1785898456903,"data":{"title":"Use the pwsh tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":1785898456904,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":7,"time":1785898456904,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":8,"time":1785678162968,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":9,"time0":1785678163361,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,0,0,0,0,17,0,0,0,0,0,0,0,1,290,0,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," PowerShell"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," execute"," it","."]}} +{"type":"assistant/chunk","seq":31,"time":1785678163671,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":32,"time0":1785678163671,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,17,0,0,0,0,0,109,0,0,0,0,0,0,22,305],"id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","args":["","{","\"","command","\"",": ","\"","[","Console","]","::","Out",".Write","('","P","WS","H","_OK","')","\"","}"]}} +{"type":"assistant/chunk","seq":53,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."}}}} +{"type":"assistant/chunk","seq":54,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}}}} +{"type":"assistant/chunk","seq":55,"time":1785678164124,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":56,"time":1785898456913,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":57,"time":1785898456913,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a PowerShell command and then reply with \"DONE\". Let me execute it."},{"type":"tool-call","id":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"82945de6-83e2-4b93-b6d2-89d58921eacf"},"usage":{"inputTokens":1255,"outputTokens":99,"cacheReadTokens":0,"reasoningTokens":22}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56],"surfaceOp":"append"} +{"type":"tool/call","seq":58,"time":1785898456913,"data":{"turn":1,"step":1,"callId":"call_00_e0MSVSocL0o4UWjOdG4c2072","name":"pwsh","arguments":"{\"command\":\"[Console]::Out.Write('PWSH_OK')\"}"}} +{"type":"tool/result","seq":59,"time":1785898456933,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_e0MSVSocL0o4UWjOdG4c2072"},"content":[{"type":"tool-result","toolCallId":"call_00_e0MSVSocL0o4UWjOdG4c2072","content":[{"type":"text","text":"PWSH_OK"}],"isError":false}],"role":"user","id":"874a846b-54b7-45cc-b3cb-edb8f868e1c5"}},"sourceEventSeqs":[58],"surfaceOp":"append"} +{"type":"step/end","seq":60,"time":1785898456933,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":61,"time":1785898456939,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":62,"time":1785678165136,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":63,"time0":1785678165312,"data":{"turn":1,"step":2,"index":0,"dt":[44,56,0,0,42,0,0,0,48,0,0,0,48,0,0,60,0,0,0,0,39,0,0,0],"texts":["The"," command"," executed"," successfully"," and"," printed"," \"","P","WS","H","_OK","\"."," Now"," I"," need"," to"," reply"," with"," \"","D","ONE","\""," and"," stop","."]}} +{"type":"assistant/chunk","seq":88,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":89,"time":1785678165649,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":90,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":91,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":92,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":93,"time":1785678165693,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":94,"time":1785898456944,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":95,"time":1785898456944,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command executed successfully and printed \"PWSH_OK\". Now I need to reply with \"DONE\" and stop."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"36aaf6a0-1556-42e4-aed3-626caa8f7aaf"},"usage":{"inputTokens":90,"outputTokens":28,"cacheReadTokens":1280,"reasoningTokens":25}},"sourceEventSeqs":[62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"} +{"type":"step/end","seq":96,"time":1785898456944,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":97,"time":1785898456944,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/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/persistent-pwsh-tool-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md new file mode 100644 index 0000000000..229b3a6f6c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/system-prompt.expected.md @@ -0,0 +1,3 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a concise snapshot agent working in {{cwd}}. diff --git a/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json new file mode 100644 index 0000000000..20f5a3e55c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/persistent-pwsh-tool-turn/tool-schemas.expected.json @@ -0,0 +1,21 @@ +{ + "initial": [ + { + "name": "pwsh", + "description": "Run commands in a persistent PowerShell shell. State, including the current directory and exported environment variables, persists across calls for this agent.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The PowerShell command to run. Relative path is preferred in the command." + } + }, + "required": [ + "command" + ] + } + } + ], + "changes": [] +} diff --git a/examples/package.json b/examples/package.json index cd348d5fe2..8b1d8875ba 100644 --- a/examples/package.json +++ b/examples/package.json @@ -95,6 +95,7 @@ "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-terminal": "workspace:*", "@deepseek-ai/dsh-tool-pwsh": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-skill": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1d23dd996..465df6b10a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -688,6 +688,9 @@ importers: '@deepseek-ai/dsh-tool-pwsh': specifier: workspace:* version: link:../packages/shell/tool-pwsh + '@deepseek-ai/dsh-tool-pwsh-persistent': + specifier: workspace:* + version: link:../packages/shell/tool-pwsh-persistent '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:../packages/workflow/tool-ralph From d0cdf520304d33c15b6ccf91a419518b26d1477c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 14:31:28 +0800 Subject: [PATCH 071/137] docs(pwsh): align persistent PTY contracts --- .../architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml | 4 ++-- .../architecture/2026-08-11-pwsh-persistent-pty.md | 6 +++--- .../architecture/2026-08-11-pwsh-persistent-pty.zh.md | 6 +++--- packages/shell/tool-pwsh-persistent/README.i18n.yaml | 4 ++-- packages/shell/tool-pwsh-persistent/README.md | 2 +- packages/shell/tool-pwsh-persistent/README.zh.md | 2 +- packages/terminal/terminal-bash/README.i18n.yaml | 4 ++-- packages/terminal/terminal-bash/README.md | 2 +- packages/terminal/terminal-bash/README.zh.md | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml index ce1e88c050..80a1959b5a 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md -2026-08-11-pwsh-persistent-pty.md: 092302ec001909683f9b7056e982309889c7f23e -2026-08-11-pwsh-persistent-pty.zh.md: 857f78c66f109ababe8a5961fa923d819857fd88 +2026-08-11-pwsh-persistent-pty.md: 8353b3ab3cdbf20add22a55acb03312c94283602 +2026-08-11-pwsh-persistent-pty.zh.md: 95048a02416dfcf5f0ef2837d99a561008f6496f diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md index 092302ec00..8353b3ab3c 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.md @@ -18,7 +18,7 @@ A model-facing persistent `pwsh` tool ships on Windows with the same contract as ### Windows substrate in `@deepseek-ai/dsh-subprocess-local` -`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32 with GetProcessTimes creation-time identities (pid-reuse fencing like the POSIX start identity), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. +`createProcessInspector()` returns a `WindowsProcessInspector` on win32 instead of throwing. The koffi-backed inspector enumerates the process table through Toolhelp32, combines GetProcessTimes creation identities with zero-time process-handle waits (pid-reuse fencing plus terminated-object detection), reports the **shell pid as a pseudo foreground group** (Windows has no POSIX groups; the stable value lets the prompt-marker readiness fast path settle in one poll interval), reports no stdin-wait evidence (readiness degrades exactly like macOS), and signals through `taskkill /T` escalation (`/F` only for SIGKILL). koffi (`^3.1.0`, the version `sandbox-windows-acl` already pins) loads lazily on win32 only. `LocalTerminalHandle` branches for win32 because node-pty's `kill(signal)` throws ("Signals not supported on windows") and its bare kill delegates to a console-list agent that fails without a parent console. Teardown escalates through taskkill fenced on the shell's start identity, and — because an externally taskkilled shell may never fire node-pty's exit notification — the handle settles `done` from the inspector-verified absence (`settleExitIfGone`). `signalForeground` maps SIGINT to a `\x03` Ctrl-C input write (the console-wide delivery conhost turns into a CTRL_C event; verified to interrupt a running command), routes SIGTERM/SIGKILL to taskkill, and rejects SIGTSTP/SIGHUP as unavailable on Windows. The public `PtySignal` set and seam types are unchanged; the mapping lives in the backend. @@ -38,7 +38,7 @@ The minimal preset gates its persistent shell stack by platform with the #2234 ` ### Testing -The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. +The Windows test surface follows master's exemption structure: terminal-bash and subprocess-local tests stay excluded on win32 (`windowsUnsupportedTests`) and their sources stay coverage-exempt there (`windowsUnsupportedCoveragePackages`), so the platform-gated fixtures and node-translated commands remain the win32 dev-lane evidence, while the koffi-backed inspector joins the windows-only coverage exclusions on Linux. `tool-pwsh-persistent` is not exempt: its suite runs and its sources are coverage-required on the windows-native lane, mirroring `tool-bash-persistent`'s stub-mode matrix plus an echo-stripping mode; the real-pwsh suites prove persistent cwd/env, secret scrubbing, multiline and here-string commands, large-output clipping, and exit/reset over real ConPTY sessions. The ACP keyless snapshot boots the persistent tool through a real Loader composition and pins its model-visible schema and result. ## Alternatives considered @@ -62,4 +62,4 @@ The Windows test surface follows master's exemption structure: terminal-bash and **Input echo is an accepted platform fact.** PSReadLine echoes submitted input; the marker-anchored extraction and wrapper-source strip remove it in complete results, with bounded residual in partial-output fallbacks. -**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the prompt function's `[Console]::` call; the `Write-Host -NoNewline` fallback is designed and decided by the Windows-native lane. A model redefinition of the `prompt` function degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. +**Risks carried.** Under the Windows ACL sandbox's read-only mode, ConstrainedLanguage may deny the bootstrap's `[Console]::` encoding pin and prompt marker; commands then settle through the printable prompt and silence tier, while non-ASCII output may follow the host code page. A model redefinition of the `prompt` function likewise degrades readiness to the silence tier. Raw ESC characters in model commands are unsupported (PSReadLine consumes them). koffi is now a dependency of the process substrate, carrying the same install/prebuild review the sandbox package already has. diff --git a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md index 857f78c66f..95048a0241 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-11-pwsh-persistent-pty.zh.md @@ -18,7 +18,7 @@ harness 在 Windows 上没有持久 shell。持久 `bash` 栈按构造就是 POS ### `@deepseek-ai/dsh-subprocess-local` 的 Windows 基座 -`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表并取 GetProcessTimes 创建时间身份(与 POSIX start-identity 相同的 PID 复用防护),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 +`createProcessInspector()` 在 win32 返回 `WindowsProcessInspector` 而不是抛错。基于 koffi 的检查器通过 Toolhelp32 枚举进程表,把 GetProcessTimes 创建身份与进程句柄零时等待结合起来(同时防止 PID 复用并识别已终止的进程对象),把 **shell pid 作为伪前台进程组**(Windows 没有 POSIX 进程组;这个稳定值让 prompt-marker 就绪快路径在一个轮询间隔内结算),不报告 stdin-wait 证据(就绪与 macOS 同档),信号走 `taskkill /T` 升级(仅 SIGKILL 加 `/F`)。koffi(`^3.1.0`,`sandbox-windows-acl` 已固定的版本)仅在 win32 惰性加载。 `LocalTerminalHandle` 为 win32 分支,因为 node-pty 的 `kill(signal)` 会抛错("Signals not supported on windows"),其无参 kill 委托的 console-list agent 在没有父控制台时失败。拆卸经 taskkill 升级并以 shell 的启动身份作栅栏;由于被外部 taskkill 的 shell 可能永远不会触发 node-pty 的退出通知,句柄从 inspector 验证的消失状态结算 `done`(`settleExitIfGone`)。`signalForeground` 把 SIGINT 映射为 `\x03` Ctrl-C 输入写入(conhost 转为控制台级 CTRL_C 事件的投递方式;实测可中断运行中的命令),SIGTERM/SIGKILL 路由到 taskkill,SIGTSTP/SIGHUP 以 Windows 不可用为由拒绝。公共 `PtySignal` 集合与 seam 类型不变;映射全部留在 backend。 @@ -38,7 +38,7 @@ minimal 预设用 #2234 的 `disabled: !!js` 插值按平台门控持久 shell ### 测试 -Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。 +Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-local 的测试在 win32 上继续排除(`windowsUnsupportedTests`),其源码在 win32 上继续覆盖豁免(`windowsUnsupportedCoveragePackages`),平台门控 fixture 与 node 翻译命令因此仍是 win32 开发车道的证据;koffi-backed inspector 在 Linux 侧加入 windows-only 覆盖豁免。`tool-pwsh-persistent` 不在豁免之列:其套件在 windows-native 车道上运行、源码受覆盖约束,镜像 `tool-bash-persistent` 的 stub 模式矩阵并加回显剥离模式;真实 pwsh 套件在真实 ConPTY 会话上证明持久 cwd/env、密钥清洗、多行与 here-string 命令、大输出裁剪与退出/重置。ACP keyless snapshot 通过真实 Loader 组合启动持久工具,并固定模型可见的 schema 与结果。 ## 备选方案 @@ -62,4 +62,4 @@ Windows 测试面沿用 master 的豁免结构:terminal-bash 与 subprocess-lo **输入回显是接受的平台事实。** PSReadLine 回显提交的输入;marker 锚定提取与包装器原文剥离在完整结果中移除它,部分输出回退中残留有界。 -**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝 prompt 函数的 `[Console]::` 调用;`Write-Host -NoNewline` 回退已设计好,由 Windows-native 车道裁决。模型重定义 `prompt` 函数会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 +**携带的风险。** Windows ACL 沙箱只读模式下,ConstrainedLanguage 可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker;此时命令通过可打印提示符和静默档结算,非 ASCII 输出可能沿用宿主代码页。模型重定义 `prompt` 函数同样会使就绪降级到静默档。模型命令中的裸 ESC 字符不受支持(PSReadLine 会吞掉)。koffi 成为进程基座的依赖,承担与沙箱包相同的安装/prebuild 评审。 diff --git a/packages/shell/tool-pwsh-persistent/README.i18n.yaml b/packages/shell/tool-pwsh-persistent/README.i18n.yaml index 0786399570..cc1175ca0e 100644 --- a/packages/shell/tool-pwsh-persistent/README.i18n.yaml +++ b/packages/shell/tool-pwsh-persistent/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/shell/tool-pwsh-persistent/README.md -README.md: 7bb66477ab7ffe52039b0c699d48c9ca761ac04c -README.zh.md: b20041b1d42908d4d1e893455d825eafa2e2f87d +README.md: a57c940801606c2eef450f0e54fecb434c62a406 +README.zh.md: 4bd7ecdad08e504daa3ff6283f8b77ac9428385f diff --git a/packages/shell/tool-pwsh-persistent/README.md b/packages/shell/tool-pwsh-persistent/README.md index 7bb66477ab..a57c940801 100644 --- a/packages/shell/tool-pwsh-persistent/README.md +++ b/packages/shell/tool-pwsh-persistent/README.md @@ -51,5 +51,5 @@ Append-only tool results follow the reusable request prefix. - A model redefinition of the `prompt` function removes the readiness marker; the shell then settles on the silence tier instead of the marker fast path. - There is no interactive stdin during a command: a foreground command that reads input blocks until the readiness timeout, which resets the shell. - SIGTSTP/SIGHUP are unavailable on Windows (backend-rejected); SIGINT is delivered as a console-wide Ctrl-C input write, which at a prompt cancels the pending line instead of signalling a process. -- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the prompt function's `[Console]::` call; the backend's documented `Write-Host -NoNewline` fallback is selected by the Windows-native lane evidence. +- Under the Windows ACL sandbox's read-only mode, pwsh starts in ConstrainedLanguage, which may deny the bootstrap's `[Console]::` encoding pin and prompt marker. Commands can still settle through the printable prompt and silence tier, but non-ASCII output may follow the host code page. - The BEL-terminated OSC marker remains a readiness signal only; a BEL event channel to the model stays deferred, aligned with the current implementation. diff --git a/packages/shell/tool-pwsh-persistent/README.zh.md b/packages/shell/tool-pwsh-persistent/README.zh.md index b20041b1d4..4bd7ecdad0 100644 --- a/packages/shell/tool-pwsh-persistent/README.zh.md +++ b/packages/shell/tool-pwsh-persistent/README.zh.md @@ -51,5 +51,5 @@ - 模型重定义 `prompt` 函数会移除就绪标记;shell 随后退化为静默档而非 marker 快路径。 - 命令执行期间没有交互 stdin:读取输入的前台命令会阻塞到就绪超时,随后重置 shell。 - SIGTSTP/SIGHUP 在 Windows 不可用(backend 拒绝);SIGINT 以控制台级 Ctrl-C 输入写入投递,在提示符处取消当前行而非向进程发信号。 -- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝 prompt 函数的 `[Console]::` 调用;backend 文档化的 `Write-Host -NoNewline` 回退由 Windows-native 车道证据裁决。 +- 在 Windows ACL 沙箱的只读模式下,pwsh 以 ConstrainedLanguage 启动,可能拒绝引导代码通过 `[Console]::` 固定编码并写入 prompt marker。命令仍可通过可打印提示符和静默档结算,但非 ASCII 输出可能沿用宿主代码页。 - BEL 终结的 OSC 标记仍只是就绪信号;面向模型的 BEL 事件通道保持延后,与当前实现对齐。 diff --git a/packages/terminal/terminal-bash/README.i18n.yaml b/packages/terminal/terminal-bash/README.i18n.yaml index e1b60c7d61..d6e544137a 100644 --- a/packages/terminal/terminal-bash/README.i18n.yaml +++ b/packages/terminal/terminal-bash/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/terminal/terminal-bash/README.md -README.md: 82d0166ff5f63c108770b0a3011e5ebb7d63b433 -README.zh.md: 10d6260360e57ee6c81fb73e96f7cb952cfc9146 +README.md: 2f3f59b1acb88ff9905e78e7fc8d0d9fbcdbf0ba +README.zh.md: f3daa0a3bc9c160236ad19b35589778d99d48b36 diff --git a/packages/terminal/terminal-bash/README.md b/packages/terminal/terminal-bash/README.md index 82d0166ff5..2f3f59b1ac 100644 --- a/packages/terminal/terminal-bash/README.md +++ b/packages/terminal/terminal-bash/README.md @@ -34,6 +34,6 @@ A standing-policy change appends an owner-rendered superseding runtime-context s - Line-oriented output is normalized; full-screen alternate-buffer interaction is unsupported. - Exact stdin-wait detection depends on the mounted subprocess provider; providers that cannot prove it use prompt-marker and silence/timeout readiness. Windows is such a provider: the shell pid is the pseudo foreground group and there is no exact stdin-wait tier, so a marker-less child settles on the silence bound. -- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny; the `Write-Host -NoNewline` fallback is the designed alternative, decided by the Windows-native lane. +- The pwsh bootstrap writes through `[Console]::` (the UTF-8 encoding pin and the prompt function), which the Windows ACL sandbox's read-only mode (ConstrainedLanguage) may deny. The shell can still settle through the controlled printable prompt and silence tier, but marker readiness is unavailable and non-ASCII output may follow the host code page. - Cleanup guarantees are those of `SubprocessTerminalHandle`; provider-specific gaps belong to that implementation's contract rather than this PTY consumer. - Sessions do not survive harness process exit. diff --git a/packages/terminal/terminal-bash/README.zh.md b/packages/terminal/terminal-bash/README.zh.md index 10d6260360..f3daa0a3bc 100644 --- a/packages/terminal/terminal-bash/README.zh.md +++ b/packages/terminal/terminal-bash/README.zh.md @@ -34,6 +34,6 @@ - 输出按行规范化;不支持全屏备用缓冲区交互。 - 精确 stdin 等待检测取决于已挂载的进程管理提供方;无法证明该状态的提供方使用提示符标记和静默/超时就绪机制。Windows 正是这样的提供方:shell pid 是伪前台进程组,没有精确的 stdin-wait 档,因此无标记的子进程按静默上限结算。 -- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它;`Write-Host -NoNewline` 回退是设计好的备选,由 Windows-native 车道裁决。 +- pwsh 引导(UTF-8 编码钉与 `prompt` 函数)通过 `[Console]::` 写入,Windows ACL 沙箱的只读模式(ConstrainedLanguage)可能拒绝它。shell 仍可通过受控可打印提示符和静默档结算,但无法使用 marker 就绪,非 ASCII 输出也可能沿用宿主代码页。 - 清理保证以 `SubprocessTerminalHandle` 的保证为准;提供方特定的缺口属于该实现的约定,而非这个 PTY 消费方。 - harness 进程退出后,会话无法继续存在。 From ee5280bd6e73668b9e73710f01e9983bbfd36ff6 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 14:38:42 +0800 Subject: [PATCH 072/137] fix(python-sdk): harden packaged runtime behavior --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 8 ++-- ...-executable-sdk-runtime-distribution.zh.md | 8 ++-- packages/fs/tool-fs-search/README.i18n.yaml | 4 +- packages/fs/tool-fs-search/README.md | 6 +-- packages/fs/tool-fs-search/README.zh.md | 6 +-- packages/fs/tool-fs-search/src/search-core.ts | 2 +- .../tool-fs-search/tests/rg-sidecar.spec.ts | 45 +++++++++++++++---- python/sdk-runtime/README.i18n.yaml | 4 +- python/sdk-runtime/README.md | 6 +-- python/sdk-runtime/README.zh.md | 6 +-- .../src/deepseek_harness_runtime/__init__.py | 9 ++-- scripts/build-exe-for-python-sdk.ts | 2 +- scripts/cordis-yaml.ts | 30 ++++++++++++- scripts/smoke-python-runtime.py | 1 - scripts/verify-cordis-config.ts | 4 +- scripts/verify-runtime-closure.spec.ts | 45 +++++++++++++++++++ scripts/verify-runtime-closure.ts | 21 ++++++--- 18 files changed, 159 insertions(+), 52 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 7594f17605..627ce281a3 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: a09c1438d61c3ad6b92d694360a1591764f3d67e -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ce6568d7303433a5a4ca57ac76f67051944ed997 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: ecc8e1e03f5660e6cf57be05909c987bfbba750d +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3b53fb19da6668a01ac0e6b2cc0570155fb5f7e1 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index a09c1438d6..ecc8e1e03f 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -42,15 +42,15 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe with its required `-rg` sidecar and optional macOS helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched `-rg` sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-sdk-jsonrpc-server` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. @@ -64,7 +64,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, discovers its tool, and completes one model-requested call. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index ce6568d730..3b53fb19da 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -42,15 +42,15 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` sidecar;pkg 运行时通过 `process.pkg` 选择该 sidecar,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` sidecar 和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` sidecar,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、sidecar 缺失或多余,以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-sdk-jsonrpc-server` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 @@ -64,7 +64,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread` ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` sidecar 调用 `glob` 与 `grep`。MCP 场景启动临时外部 stdio server,发现其工具,并完成一次由模型请求的调用。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index cd3819ad4d..4da9a9328d 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/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-search/README.md -README.md: 83290df98260e977a8cd3ea808f491ea63e75857 -README.zh.md: b33fe002fc4ccc2bfe5fefec342f2044e3511078 +README.md: 84a3adc31f9c1580b90c038b0902e88b050f0340 +README.zh.md: 9f815e685eae93b7c184e529692c0c73fc8ba4bf diff --git a/packages/fs/tool-fs-search/README.md b/packages/fs/tool-fs-search/README.md index 83290df982..84a3adc31f 100644 --- a/packages/fs/tool-fs-search/README.md +++ b/packages/fs/tool-fs-search/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by the **packaged ripgrep binary** (`@vscode/ripgrep`), not by `ctx.fs` provider methods and not by a system `rg` install. Registration is unconditional: the binary ships inside the npm dependency, so there is no load-time availability probe. Each call spawns the binary through the `ctx.subprocess` seam with a fixed argv vector (`--no-config` prepended so a host `RIPGREP_CONFIG_PATH` cannot inject a `--pre` preprocessor into the unconfined spawn; model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. +The **model-facing filesystem discovery tools**—`glob`, `grep`—are backed by a packaged ripgrep binary, not by `ctx.fs` provider methods and not by a system `rg` install. Ordinary Node deployments resolve the platform binary from `@vscode/ripgrep`; a pkg single-file runtime resolves the executable's co-located `-rg` sidecar and falls back to the dependency binary when that sidecar is absent. Registration is unconditional because both carriers package ripgrep, so there is no load-time availability probe. Each call spawns the resolved binary through the `ctx.subprocess` seam with a fixed argv vector (`--no-config` prepended so a host `RIPGREP_CONFIG_PATH` cannot inject a `--pre` preprocessor into the unconfined spawn; model-controlled values are plain argv elements — no shell layer exists, so no quoting applies), parses the raw `rg` output, and returns a workdir-relative canonical value. The package injects `tools`, `systemPrompt`, and `subprocess`—deliberately **not** `fs`; `ctx.spillStore` is read opportunistically with `ctx.get()` because formatted-result spill is optional. ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -16,7 +16,7 @@ Why spawn-backed: local workspace discovery is naturally a process-backed `rg` w ## Deployment requirement: no host rg, co-located workdir/filesystem -The binary ships with the package on every supported platform (macOS/Linux/Windows, x64/arm64), so no host `rg` install is required and the tools register on every deployment. Returned paths are displayed relative to the resolved workdir (the calling agent's session cwd when present, else `process.cwd()`) and are follow-up-readable with `read` only when that workdir and the filesystem root are the same workspace. That co-location requirement carries no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. +Node deployments receive the `@vscode/ripgrep` platform package on supported macOS, Linux, and Windows x64/arm64 targets. Python SDK Linux and macOS wheels copy the target-native binary beside the single-file runtime as `-rg`; `deepseek_harness_runtime.bundled_runtime_path()` rejects an incomplete wheel before launch. No carrier requires a host `rg` install. Returned paths are displayed relative to the resolved workdir (the calling agent's session cwd when present, else `process.cwd()`) and are follow-up-readable with `read` only when that workdir and the filesystem root are the same workspace. That co-location requirement carries no runtime cross-service validation; remote or virtual filesystem search waits for a shared workspace contract or a provider-specific search backend. ## Config @@ -129,6 +129,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **Search and file access have no shared-workspace proof** — returned paths are follow-up-readable only when the workdir and filesystem root denote the same workspace; the package performs no runtime cross-service validation. -- **The packaged binary is fixed at dependency version** — `@vscode/ripgrep` covers the platforms it ships (macOS/Linux/Windows, x64/arm64); an unsupported platform or a corrupted install fails calls with `SEARCH_FAILED`. Remote or virtual filesystems need a co-located workspace or another search consumer. +- **The packaged binary is fixed at dependency version** — Node deployments use the version selected by `@vscode/ripgrep`; Python single-file runtimes copy that target-native version into the required `-rg` sidecar. An unsupported platform or a corrupted installation fails with `SEARCH_FAILED`, while the Python runtime package rejects a missing sidecar before launch. Remote or virtual filesystems need a co-located workspace or another search consumer. - **The schemas expose one bounded page** — offset pagination, case-mode switches, alternate output modes, and provider-backed discovery remain outside this package; capped complete output requires a spill backend. - **Sampling, when enabled, groups by first path segment beneath the search root only** — an over-cap `glob` page balances across those top-level entries, so a result concentrated deeper (one busy directory inside an otherwise even tree) is still shown unevenly below that level; recursive balancing is deferred. diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index b33fe002fc..9f815e685e 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**面向模型的文件系统发现工具**(`glob`、`grep`)由 **打包的 ripgrep 二进制**(`@vscode/ripgrep`)支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。注册是无条件的:二进制随 NPM 依赖一起交付,因此没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 该二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此不涉及 shell 引号处理),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 +**面向模型的文件系统发现工具**(`glob`、`grep`)由打包的 ripgrep 二进制支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。普通 Node 部署从 `@vscode/ripgrep` 解析平台二进制;pkg 单文件运行时解析与可执行程序共置的 `-rg` sidecar,sidecar 缺失时回退到依赖中的二进制。两种载体均打包 ripgrep,因此注册是无条件的,没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 解析出的二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此不涉及 shell 引号处理),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -16,7 +16,7 @@ await ctx.plugin(LocalSpillStore) // @deepseek-ai/dsh- ## 部署要求:无需宿主 rg,但工作目录与文件系统需共置 -二进制随包交付,覆盖所有受支持平台(macOS/Linux/Windows,x64/arm64),因此无需宿主 `rg` 安装,工具在每个部署上都注册。返回路径会相对于解析后的工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用 `process.cwd()`);只有该工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。这项共置要求不附带运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区约定或特定提供方的搜索后端。 +Node 部署在受支持的 macOS、Linux 与 Windows x64/arm64 目标上获得 `@vscode/ripgrep` 平台包。Python SDK 的 Linux 与 macOS wheel 将目标原生二进制复制到单文件运行时旁,命名为 `-rg`;`deepseek_harness_runtime.bundled_runtime_path()` 会在启动前拒绝不完整的 wheel。两种载体均不要求宿主安装 `rg`。返回路径会相对于解析后的工作目录显示(调用方 agent(智能体)有会话 cwd 时使用该 cwd,否则使用 `process.cwd()`);只有该工作目录与文件系统根目录是同一工作区时,才能用 `read` 继续读取。这项共置要求不附带运行时跨服务校验;远程或虚拟文件系统搜索需等待共享工作区约定或特定提供方的搜索后端。 ## 配置 @@ -129,6 +129,6 @@ glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `g ## 已知限制与暂缓事项 - **搜索与文件访问没有共享工作区证明**——只有当工作目录与文件系统根目录指向同一工作区时,返回路径才可继续读取;本包不执行运行时跨服务校验。 -- **打包二进制固定在依赖版本上**——`@vscode/ripgrep` 覆盖其随附的平台(macOS/Linux/Windows,x64/arm64);不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 +- **打包二进制固定在依赖版本上**——Node 部署使用 `@vscode/ripgrep` 选择的版本;Python 单文件运行时将对应目标的原生版本复制为必需的 `-rg` sidecar。不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败,Python 运行时包则会在启动前拒绝缺失 sidecar 的安装。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 - **schema 只暴露一个有界页面**——偏移分页、大小写开关、替代输出模式与提供方支撑的发现仍不在本包范围内;达到上限的完整输出需要 spill 后端。 - **启用采样时仅按搜索根正下方的第一段路径分组**——超过上限的 `glob` 页面在这些顶层条目之间平衡,因此集中在更深处的结果(一棵均匀树里某个繁忙目录)在该层级之下仍会呈现不均;递归平衡被延期。 diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index e4580f7301..5ac5521033 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -171,7 +171,7 @@ let rgPathPromise: Promise | undefined export function resolveRgPath(): Promise { rgPathPromise ??= Promise.resolve().then(async () => { const executableSidecar = `${process.execPath}-rg` - if (existsSync(executableSidecar)) return executableSidecar + if ('pkg' in process && existsSync(executableSidecar)) return executableSidecar return (await import('@vscode/ripgrep')).rgPath }) return rgPathPromise diff --git a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts index 49cca6bfa8..53c7a01aea 100644 --- a/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts +++ b/packages/fs/tool-fs-search/tests/rg-sidecar.spec.ts @@ -1,25 +1,52 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const existsSync = vi.hoisted(() => vi.fn(() => true)) +const { dependencyRgPath, existsSync } = vi.hoisted(() => ({ + dependencyRgPath: '/node_modules/@vscode/ripgrep/bin/rg', + existsSync: vi.fn(), +})) vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() return { ...actual, existsSync } }) -vi.mock('@vscode/ripgrep', () => new Proxy({}, { - get() { - throw new Error('the platform package must not load when the executable sidecar exists') - }, -})) +vi.mock('@vscode/ripgrep', () => ({ rgPath: dependencyRgPath })) -import { resolveRgPath } from '@deepseek-ai/dsh-tool-fs-search' +beforeEach(() => { + vi.resetModules() + existsSync.mockReset() + Reflect.deleteProperty(process, 'pkg') +}) -describe('single-executable ripgrep resolution', () => { +afterEach(() => { + Reflect.deleteProperty(process, 'pkg') +}) + +describe('ripgrep resolution', () => { it('uses the native sidecar beside the current executable', async () => { + Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} }) + existsSync.mockReturnValue(true) const sidecar = `${process.execPath}-rg` + const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') await expect(resolveRgPath()).resolves.toBe(sidecar) expect(existsSync).toHaveBeenCalledWith(sidecar) }) + + it('uses the dependency binary in an ordinary Node process', async () => { + existsSync.mockReturnValue(true) + const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') + + await expect(resolveRgPath()).resolves.toBe(dependencyRgPath) + expect(existsSync).not.toHaveBeenCalled() + }) + + it('uses the dependency binary when a packaged runtime has no sidecar', async () => { + Reflect.defineProperty(process, 'pkg', { configurable: true, value: {} }) + existsSync.mockReturnValue(false) + const { resolveRgPath } = await import('@deepseek-ai/dsh-tool-fs-search') + + await expect(resolveRgPath()).resolves.toBe(dependencyRgPath) + expect(existsSync).toHaveBeenCalledWith(`${process.execPath}-rg`) + }) }) diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index d04296dd33..96739c3baa 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-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 python/sdk-runtime/README.md -README.md: c00357dbad74f8de705789bce8e4a55cc5fd67b1 -README.zh.md: a62edb4cd98338d22332f8e124c3ecb3f2356ace +README.md: 597d69a803a7cd1204fd48456f8e1ba18d9786e5 +README.zh.md: 888ecdad437d001b84b1af71c04e2da016d3901e diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index c00357dbad..597d69a803 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. +- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) with a target-native ripgrep `-rg` sidecar. macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. @@ -17,12 +17,12 @@ The bundled plugin set includes `@deepseek-ai/dsh-mcp-client`, so an external Co A missing exe raises `FileNotFoundError` naming both acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. A missing dev-only node carrier names its sole route, the build script. The workflow retains wheels rather than standalone executable archives. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. -Each wheel contains exactly one runtime executable. The macOS wheel also contains its matching native spawn helper; a missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime files, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. +Each wheel contains exactly one runtime executable and its matching ripgrep `-rg` sidecar. The macOS wheel also contains its matching native spawn helper; any missing sidecar makes that installation incomplete and is a hard startup error, even for a selected Cordis composition that does not use filesystem-search or PTY tools. Linux wheels contain no spawn helper because `node-pty` uses the staged `pty.node` addon directly. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_14_0_arm64`; the macOS tag conservatively matches the bundled Node 24 executable's macOS 13.5 deployment target. This package's `platforms.json` owns the fixed tag and executable-name pairs used by both the repository release builder and the isolated build hook. The build hook rejects `py3-none-any`, absent or multiple runtime executables, missing or extra sidecars, non-executable files, and unsupported platform tags. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-v` release tag must match it. ## Resolution API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. -- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; on macOS it validates that the required sibling `-spawn-helper` is also installed). The node carrier has no single-path equivalent and launches via the argv tuple above. +- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only); it validates the required sibling `-rg` sidecar on every platform and the `-spawn-helper` sidecar on macOS. The node carrier has no single-path equivalent and launches via the argv tuple above. - `bundled_default_config_path() -> Path` — the checked-in default config (see below). - `bundled_package_dir() -> Path` — the installed package data root. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index a62edb4cd9..888ecdad43 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),以及匹配目标平台的 ripgrep `-rg` sidecar。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 @@ -17,12 +17,12 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少伴随文件意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在运行时文件、存在多个运行时文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件及其匹配的 ripgrep `-rg` sidecar。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少任一 sidecar 都意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用文件系统搜索或 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在或存在多个运行时可执行文件、缺失或多余的 sidecar、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 ## 解析 API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体,并会在 macOS 上校验必要的 `-spawn-helper` 伴随文件也已安装)。node 载体没有单一路径的等价物,经由上面的 argv 元组启动。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体);它会在所有平台校验必要的 `-rg` sidecar,并在 macOS 上额外校验 `-spawn-helper` sidecar。node 载体没有单一路径的等价物,经由上面的 argv 元组启动。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 727ee29586..a17a57ca70 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -71,10 +71,11 @@ def bundled_runtime_path() -> Path: """Absolute path of the bundled single-file runtime executable for the current platform. Raises FileNotFoundError when the platform is unsupported, the executable - has not been placed into this package, or the required macOS spawn helper is - missing; the message names the acquisition routes (acquisition strategy is - deliberately separate from this lookup interface, so an on-demand download - can replace it without touching callers). + has not been placed into this package, the required ripgrep sidecar is + missing, or the required macOS spawn helper is missing; the message names + the acquisition routes (acquisition strategy is deliberately separate from + this lookup interface, so an on-demand download can replace it without + touching callers). """ tag = _current_platform_tag() path = bundled_package_dir() / "runtime" / f"dsh-jsonrpc-agent-pkg-{tag}" diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index ae35802cc9..4773a506b0 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -378,7 +378,7 @@ class SingleExeBuild { /** * Package one target; SEA mode accepts one target per invocation. * @param target - the pkg target triple to build. - * @returns the executable path and, on macOS, its helper path. + * @returns the executable and ripgrep sidecar paths, plus the macOS spawn helper path when required. */ async pack(target: Target): Promise { const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) diff --git a/scripts/cordis-yaml.ts b/scripts/cordis-yaml.ts index 60cd492787..e7db335c6a 100644 --- a/scripts/cordis-yaml.ts +++ b/scripts/cordis-yaml.ts @@ -1,5 +1,11 @@ +/** + * Cordis YAML parsing and Loader-entry classification shared by repository checks. + * @module scripts/cordis-yaml + */ + import * as yaml from 'js-yaml' +/** A Loader `!!js` expression preserved as data instead of executed. */ export interface JsExpr { __jsExpr: string } @@ -14,13 +20,35 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', { }) const schema = yaml.JSON_SCHEMA.extend(jsExprType) -/** Parse a Cordis config while preserving Loader `!!js` expressions as data. */ +/** + * Parse a Cordis config while preserving Loader `!!js` expressions as data. + * @param source - Cordis YAML source text. + * @returns the parsed YAML value. + */ export function loadCordisYaml(source: string): unknown { return yaml.load(source, { schema }) } +/** + * Test whether a value is a preserved Loader `!!js` expression. + * @param value - parsed YAML value. + * @returns whether the value contains one preserved expression. + */ export function isJsExpr(value: unknown): value is JsExpr { return typeof value === 'object' && value !== null && typeof (value as Record).__jsExpr === 'string' } + +/** + * Test whether a Loader entry owns nested entries in its `config` array. + * @param value - parsed Loader entry. + * @returns whether the entry is an explicit or package-named Cordis group. + */ +export function isCordisGroupEntry(value: unknown): value is Record & { config: unknown[] } { + return typeof value === 'object' + && value !== null + && Array.isArray((value as Record).config) + && ((value as Record).group === true + || (value as Record).name === '@deepseek-ai/cordis-plugin-group') +} diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 76f3e4f60a..b478efe5e0 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -29,7 +29,6 @@ WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor." MINIMAL_TEXT = "minimal agent smoke ok" MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " -MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." FS_SEARCH_PROMPT = "Exercise the packaged filesystem search tools." FS_SEARCH_TEXT = "filesystem search smoke ok" FS_SEARCH_MARKER = "PACKAGED_FS_SEARCH_OK" diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 9777979f5f..d47ce395b8 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -15,7 +15,7 @@ import { dirname, relative, resolve } from 'node:path' import { Script } from 'node:vm' import ts from 'typescript' import { cordisConfigFiles } from './cordis-config-files.ts' -import { isJsExpr, loadCordisYaml } from './cordis-yaml.ts' +import { isCordisGroupEntry, isJsExpr, loadCordisYaml } from './cordis-yaml.ts' interface PackageManifest { name?: string @@ -191,7 +191,7 @@ function validateEntry(value: unknown, file: string, path: string): void { } recordPlugin(value, file) validateMetadata(value, file, path) - if ((value.group === true || value.name === '@deepseek-ai/cordis-plugin-group') && isUnknownArray(value.config)) { + if (isCordisGroupEntry(value)) { for (let index = 0; index < value.config.length; index++) { validateEntry(value.config[index], file, `${path}.config[${index}]`) } diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index ccce67c498..50d97e0dae 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -82,6 +82,51 @@ describe('verifyRuntimeClosure', () => { ]) }) + it('does not interpret an ordinary plugin array config as nested Loader entries', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/plugin': 'workspace:^' } }, + 'python/sdk-runtime/platforms.json': platforms, + 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` +- id: plugin + name: '@scope/plugin' + config: + - name: '@scope/config-value' +`, + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.failures).toEqual([]) + }) + + it('fails when no shipped preset is discovered', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, + 'python/sdk-runtime/platforms.json': platforms, + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.presetCount).toBe(0) + expect(result.failures).toEqual([ + 'no agent presets matched apps/cli/config/agent-presets/*/agent.cordis.yml', + ]) + }) + + it('fails when the runtime platform manifest has no targets', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, + 'python/sdk-runtime/platforms.json': {}, + 'apps/cli/config/agent-presets/standard/agent.cordis.yml': '[]\n', + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.failures).toEqual([ + 'python/sdk-runtime/platforms.json defines no runtime targets', + ]) + }) + it('retains the required workspace-peer closure check', async () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/root': 'workspace:^' } }, diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index c62ef68e37..549c06e9ad 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -8,7 +8,7 @@ import { globSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' import { parseArgs } from 'node:util' -import { loadCordisYaml } from './cordis-yaml.ts' +import { isCordisGroupEntry, loadCordisYaml } from './cordis-yaml.ts' interface PackageManifest { name?: string @@ -30,6 +30,8 @@ interface RuntimePlatform { type RuntimePlatformManifest = Record +const AGENT_PRESET_GLOB = 'apps/cli/config/agent-presets/*/agent.cordis.yml' + export interface RuntimeClosureResult { failures: string[] presetCount: number @@ -51,6 +53,8 @@ export async function verifyRuntimeClosure( const workspace = await loadWorkspacePackages(root) const runtimeDependencies = runtimeManifest.dependencies ?? {} const platforms = await loadJson(resolve(root, 'python/sdk-runtime/platforms.json')) + const presetPaths = globSync(AGENT_PRESET_GLOB, { cwd: root }).sort() + const targets = Object.keys(platforms).sort() const parents = new Map() const queue: string[] = [] @@ -60,7 +64,10 @@ export async function verifyRuntimeClosure( queue.push(dependency) } - const failures = await missingPresetPlugins(root, runtimeDependencies, platforms) + const failures: string[] = [] + if (presetPaths.length === 0) failures.push(`no agent presets matched ${AGENT_PRESET_GLOB}`) + if (targets.length === 0) failures.push('python/sdk-runtime/platforms.json defines no runtime targets') + failures.push(...await missingPresetPlugins(root, runtimeDependencies, presetPaths, targets)) for (let index = 0; index < queue.length; index += 1) { const packageName = queue[index] if (packageName === undefined) continue @@ -86,7 +93,7 @@ export async function verifyRuntimeClosure( return { failures, - presetCount: globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).length, + presetCount: presetPaths.length, workspacePackageCount: queue.length, } } @@ -112,18 +119,18 @@ if (import.meta.main) { async function missingPresetPlugins( root: string, runtimeDependencies: Readonly>, - platforms: RuntimePlatformManifest, + presetPaths: readonly string[], + targets: readonly string[], ): Promise { const missing = new Map>() const failures: string[] = [] - const presetPaths = globSync('apps/cli/config/agent-presets/*/agent.cordis.yml', { cwd: root }).sort() for (const presetPath of presetPaths) { const document = loadCordisYaml(await readFile(resolve(root, presetPath), 'utf8')) if (!Array.isArray(document)) { failures.push(`${presetPath}: preset root must be a Loader entry array`) continue } - for (const target of Object.keys(platforms).sort()) { + for (const target of targets) { const processPlatform = processPlatformForTarget(target) for (const plugin of activeBarePluginPackages(document, processPlatform)) { if (runtimeDependencies[plugin] !== undefined) continue @@ -150,7 +157,7 @@ function activeBarePluginPackages(entries: unknown[], processPlatform: string): const packageName = barePackageName(value.name) if (packageName !== undefined) packages.add(packageName) } - if (Array.isArray(value.config)) { + if (isCordisGroupEntry(value)) { for (const child of value.config) visit(child, disabled) } } From 5648d4ad1c67f5785f5d622021af832582bb1674 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 16:38:44 +0800 Subject: [PATCH 073/137] test(typert): poll for the steady-state registration failure log --- packages/typert/loader/tests/loader.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 6dccd2b5dd..2f4b4cae04 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -398,9 +398,10 @@ describe('typert loader', () => { await ctx.loader.create({ name: '@fixture/steady-failure' }) await ctx.loader.await() - await new Promise(resolve => setTimeout(resolve, 20)) - - expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' })) + // The failing contributor's error is reported on the post-await flush. + await vi.waitFor(() => { + expect(logged).toHaveBeenCalledWith(expect.objectContaining({ message: 'register failed' })) + }, { timeout: 10_000 }) expect(ctx.typert.getPackage('@fixture/steady-failure')).toBeUndefined() }) }) From dc8991879ac3cb3fa80e6765f773a71dd3aeeac1 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 16:38:52 +0800 Subject: [PATCH 074/137] test(agent-instructions): extend the workspace-context wait budget --- .../context/agent-instructions/tests/agent-instructions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 171f7322f0..52e2e79e00 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -223,7 +223,7 @@ async function workspaceContextOf(agent: Agent): Promise { message.source.kind === 'agent-instructions') expect(context).toBeDefined() return context! - }) + }, { timeout: 10_000 }) } async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise { From 4f64e20b417809ac8dbd681a450abc27ce768262 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 16:43:59 +0800 Subject: [PATCH 075/137] test(llm-pi-ai): cover compat validation branches --- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index f6f80eb878..83fd8958d0 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -919,6 +919,29 @@ describe('compat switches', () => { }) }) + it('ignores compat entries whose value is undefined', () => { + const models = modelsOf({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsStore: undefined }, + models: [{ id: 'acme-a' }], + }, + }, 'acme-gateway') + + expect(models.get('acme-a')?.compat).toBeUndefined() + }) + + it('rejects a model switch on an unrecognized protocol as having no configurable compat', () => { + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'acme-chat', + baseURL: 'https://acme.test', + models: [{ id: 'acme-a', compat: { supportsStore: false } }], + }, + })).toThrow(/its api is "acme-chat", which does not take it.*"acme-chat" offers no configurable compat/s) + }) + it('refuses a compat key no wire protocol declares instead of dropping it', () => { // The silent drop is what let an unreadable switch look applied: schemastery // passes unknown keys through, and resolution used to read only two fields. From 78fc31d06b8cd02736573191f9254a8a114e3e86 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 18 Aug 2026 17:13:47 +0800 Subject: [PATCH 076/137] test(llm-pi-ai): remove unreachable compat fallback --- packages/llm/llm-pi-ai/src/catalog.ts | 1 - packages/llm/llm-pi-ai/tests/catalog.spec.ts | 13 ------------- 2 files changed, 14 deletions(-) diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 5a42ac9220..c945800bea 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -356,7 +356,6 @@ export type EveryOfferedFieldIsDocumented = AssertNever { - if (value === undefined) return [] const empty = typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value as object).length === 0 return empty ? [] : [[field, value] as const] diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 83fd8958d0..8997556871 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -919,19 +919,6 @@ describe('compat switches', () => { }) }) - it('ignores compat entries whose value is undefined', () => { - const models = modelsOf({ - 'acme-gateway': { - api: 'openai-completions', - baseURL: 'https://acme.test', - compat: { supportsStore: undefined }, - models: [{ id: 'acme-a' }], - }, - }, 'acme-gateway') - - expect(models.get('acme-a')?.compat).toBeUndefined() - }) - it('rejects a model switch on an unrecognized protocol as having no configurable compat', () => { expect(() => resolveProfiles({ 'acme-gateway': { From 7bb766fc82ce7dd557650e4adb8d1ef6dd5e998a Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 17:36:59 +0800 Subject: [PATCH 077/137] fix(python-sdk): make runtime readiness explicit --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 4 +- ...-executable-sdk-runtime-distribution.zh.md | 10 ++-- packages/fs/tool-fs-search/README.i18n.yaml | 2 +- packages/fs/tool-fs-search/README.zh.md | 4 +- packages/sdk/server/README.i18n.yaml | 4 +- packages/sdk/server/README.md | 2 +- packages/sdk/server/README.zh.md | 2 +- packages/sdk/server/src/index.ts | 6 ++ .../sdk/server/tests/plugin-apply.spec.ts | 60 ++++++++++++++++++- python/sdk-runtime/README.i18n.yaml | 2 +- python/sdk-runtime/README.zh.md | 6 +- scripts/smoke-python-runtime.py | 20 ++----- scripts/verify-runtime-closure.spec.ts | 17 ++++++ scripts/verify-runtime-closure.ts | 8 ++- 15 files changed, 113 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 627ce281a3..21c607072c 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: ecc8e1e03f5660e6cf57be05909c987bfbba750d -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3b53fb19da6668a01ac0e6b2cc0570155fb5f7e1 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 54030fa4b0742fbc282bc327b0ca22747e6a20bd diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index ecc8e1e03f..40433d99e5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,7 +36,7 @@ Config discovery has two channels and fails loudly when both are missing: the `D Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. @@ -64,7 +64,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, discovers its tool, and completes one model-requested call. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 3b53fb19da..54030fa4b0 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,21 +36,21 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都显式列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` sidecar;pkg 运行时通过 `process.pkg` 选择该 sidecar,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` sidecar 和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` sidecar,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、sidecar 缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-sdk-jsonrpc-server` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 @@ -64,7 +64,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread` ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` sidecar 调用 `glob` 与 `grep`。MCP 场景启动临时外部 stdio server,发现其工具,并完成一次由模型请求的调用。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/packages/fs/tool-fs-search/README.i18n.yaml b/packages/fs/tool-fs-search/README.i18n.yaml index 4da9a9328d..29133d7e55 100644 --- a/packages/fs/tool-fs-search/README.i18n.yaml +++ b/packages/fs/tool-fs-search/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs-search/README.md README.md: 84a3adc31f9c1580b90c038b0902e88b050f0340 -README.zh.md: 9f815e685eae93b7c184e529692c0c73fc8ba4bf +README.zh.md: 06ee6c7c1c1296c7c23b2c65cf244c1449def313 diff --git a/packages/fs/tool-fs-search/README.zh.md b/packages/fs/tool-fs-search/README.zh.md index 9f815e685e..06ee6c7c1c 100644 --- a/packages/fs/tool-fs-search/README.zh.md +++ b/packages/fs/tool-fs-search/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**面向模型的文件系统发现工具**(`glob`、`grep`)由打包的 ripgrep 二进制支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。普通 Node 部署从 `@vscode/ripgrep` 解析平台二进制;pkg 单文件运行时解析与可执行程序共置的 `-rg` sidecar,sidecar 缺失时回退到依赖中的二进制。两种载体均打包 ripgrep,因此注册是无条件的,没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 解析出的二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此不涉及 shell 引号处理),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 +**面向模型的文件系统发现工具**(`glob`、`grep`)由打包的 ripgrep 二进制支持,而不是由 `ctx.fs` 提供方方法或系统 `rg` 安装支持。普通 Node 部署从 `@vscode/ripgrep` 解析平台二进制;pkg 单文件运行时解析与可执行程序共置的 `-rg` 伴随文件,伴随文件缺失时回退到依赖中的二进制。两种载体均打包 ripgrep,因此注册是无条件的,没有加载期可用性探针。每次调用都通过 `ctx.subprocess` seam 以固定 argv 向量 spawn 解析出的二进制(前缀 `--no-config`,使宿主的 `RIPGREP_CONFIG_PATH` 无法向不受约束的 spawn 注入 `--pre` 预处理器;模型控制的值是普通 argv 元素——不存在 shell 层,因此不涉及 shell 引号处理),解析原始 `rg` 输出,并返回相对于工作目录的规范值。本包注入 `tools`、`systemPrompt` 和 `subprocess`,有意**不**注入 `fs`;格式化结果 spill 为可选功能,因此机会性读取 `ctx.spillStore`,调用方式为 `ctx.get()`。 ```ts ignore-check // A deployment chooses how over-cap glob pages are selected. @@ -129,6 +129,6 @@ glob 描述声明了配置的超过上限排序方式。生成的 [`glob` 和 `g ## 已知限制与暂缓事项 - **搜索与文件访问没有共享工作区证明**——只有当工作目录与文件系统根目录指向同一工作区时,返回路径才可继续读取;本包不执行运行时跨服务校验。 -- **打包二进制固定在依赖版本上**——Node 部署使用 `@vscode/ripgrep` 选择的版本;Python 单文件运行时将对应目标的原生版本复制为必需的 `-rg` sidecar。不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败,Python 运行时包则会在启动前拒绝缺失 sidecar 的安装。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 +- **打包二进制固定在依赖版本上**——Node 部署使用 `@vscode/ripgrep` 选择的版本;Python 单文件运行时将对应目标的原生版本复制为必需的 `-rg` 伴随文件。不支持的平台或损坏的安装会以 `SEARCH_FAILED` 使调用失败,Python 运行时包则会在启动前拒绝缺少伴随文件的安装。远程或虚拟文件系统需要共置的工作区或另一个搜索消费方。 - **schema 只暴露一个有界页面**——偏移分页、大小写开关、替代输出模式与提供方支撑的发现仍不在本包范围内;达到上限的完整输出需要 spill 后端。 - **启用采样时仅按搜索根正下方的第一段路径分组**——超过上限的 `glob` 页面在这些顶层条目之间平衡,因此集中在更深处的结果(一棵均匀树里某个繁忙目录)在该层级之下仍会呈现不均;递归平衡被延期。 diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 98523811ba..4f277446d3 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/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/sdk/server/README.md -README.md: 5377b4fcf425cc5e10497e9d40fdddc075d52a10 -README.zh.md: dcca65a7175bb2774460bf265d98e41439df6a01 +README.md: 29ad5840b9d70c9c22ecd387730ba21ce89cbe07 +README.zh.md: f5afb255c83fa62fa4c2891cf725ffbf4c93e77d diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index 5377b4fcf4..29ad5840b9 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -22,7 +22,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from `cordis.yml`. +`initialize` is the runtime-readiness boundary: when the server is mounted by a Loader composition, it waits for the current plugin tree to settle before replying, so async sibling capabilities such as initial MCP tool discovery are visible to the first prompt. Hand-built contexts without Loader remain immediately usable. `initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no SDK cap and allows the selected adapter or provider route default to apply. `session/prompt` queues one identified user message and immediately returns `{ messageId }`. The server streams every durable fact as `session.event` and every whole-agent lifecycle transition as `session.status`; it does not assign an assistant message or `turn/end` to that prompt. Independent requests may enqueue more work on the same session. Persistence roots and persona come from `cordis.yml`. ## Model Experience diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index dcca65a717..f5afb255c8 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -22,7 +22,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 ## 协议说明 -`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由 `cordis.yml` 提供。 +`initialize` 是运行时就绪边界:服务器由 Loader 组合挂载时,会等待当前插件树完成所有加载任务后再响应,因此首次提示词能够看到 MCP 初始工具发现等异步同级能力。没有 Loader 的手工组装上下文仍可立即使用。`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送 SDK 上限,并应用所选适配器或提供方路由的默认值。`session/prompt` 将一条带标识的用户消息排入队列,并立即返回 `{ messageId }`。服务器将每个持久事实作为 `session.event` 流式发出,并将整个 agent 生命周期的每次状态转换作为 `session.status` 发出;它不会把某条助手消息或 `turn/end` 归属于该提示词。同一会话上的独立请求可以继续排入更多工作。持久化根目录和 persona 由 `cordis.yml` 提供。 ## 模型体验 diff --git a/packages/sdk/server/src/index.ts b/packages/sdk/server/src/index.ts index 20cb892b53..4cad576894 100644 --- a/packages/sdk/server/src/index.ts +++ b/packages/sdk/server/src/index.ts @@ -74,6 +74,12 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { } transport.onRequest(async (method, params) => { + // `initialize` is the SDK's readiness boundary. This plugin can activate + // before async sibling Loader entries (for example an MCP client's initial + // tool discovery), so do not advertise a ready runtime until the complete + // current tree has settled. A hand-built context without Loader remains + // immediately usable. + if (method === 'initialize') await ctx.get('loader')?.await() const result = await server.handleRequest(method, params) if (method === 'shutdown') { // Run after the handler result is written; the task then flushes, disposes, and exits. diff --git a/packages/sdk/server/tests/plugin-apply.spec.ts b/packages/sdk/server/tests/plugin-apply.spec.ts index 320a61ac63..e2c86dbc61 100644 --- a/packages/sdk/server/tests/plugin-apply.spec.ts +++ b/packages/sdk/server/tests/plugin-apply.spec.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { PassThrough, Writable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as jsonrpc from '../src/index.ts' @@ -57,12 +58,17 @@ async function settle(): Promise { /** Mount the real plugin on a minimal harness with in-memory stdio and exit. */ async function mountPlugin( storageDir: string, - options: { writeDelayMs?: number; failFlush?: boolean } = {}, + options: { + writeDelayMs?: number + failFlush?: boolean + beforeServer?: (ctx: Context) => Promise | void + } = {}, ): Promise { const ctx = new Context() await ctx.plugin(agentCore, { workspaceContext: false }) await ctx.plugin(JsonlSessionPersistence, { root: storageDir }) await new Promise(resolve => setTimeout(resolve, 50)) + await options.beforeServer?.(ctx) const input = new PassThrough() const events: WireEvent[] = [] @@ -170,6 +176,58 @@ describe('dsh-sdk-jsonrpc-server plugin apply', () => { } }) + it('does not answer initialize until async sibling Loader entries settle', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-readiness-')) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + let markStarted!: () => void + let release!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + const ready = new Promise((resolve) => { release = resolve }) + let delayedEntry: Promise | undefined + const harness = await mountPlugin(storageDir, { + beforeServer: async (ctx) => { + await ctx.plugin(Loader) + ctx.loader.builtins['delayed-readiness'] = { + async apply() { + markStarted() + await ready + }, + } + delayedEntry = ctx.loader.create({ name: 'cordis:delayed-readiness' }) + await started + }, + }) + try { + const initialize = { + jsonrpc: '2.0', + id: 'init-delayed', + method: 'initialize', + params: { cwd: storageDir, provider: 'deepseek-official', model: 'apply-model' }, + } + const probe = { jsonrpc: '2.0', id: 'probe-during-delay', method: 'nope/unknown' } + harness.sendRaw(`${JSON.stringify(initialize)}\n${JSON.stringify(probe)}\n`) + + // The transport processes independent requests concurrently. Receiving + // this later probe proves the preceding initialize handler has reached + // its Loader wait, without relying on a scheduler delay. + await harness.waitForFrame(frame => frame.id === 'probe-during-delay', 'probe while initialize waits') + expect(harness.frames().some(frame => frame.id === 'init-delayed')).toBe(false) + + release() + await delayedEntry + const response = await harness.waitForFrame(frame => frame.id === 'init-delayed', 'initialize response after Loader settlement') + expect(response).toMatchObject({ + id: 'init-delayed', + result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } }, + }) + } finally { + release() + await Promise.allSettled(delayedEntry === undefined ? [] : [delayedEntry]) + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('drives a session/prompt turn end-to-end and forwards session notifications as output frames', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-prompt-')) const llmServer = await mockCompletionServer() diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 96739c3baa..b8eacd05a3 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md README.md: 597d69a803a7cd1204fd48456f8e1ba18d9786e5 -README.zh.md: 888ecdad437d001b84b1af71c04e2da016d3901e +README.zh.md: 30dc6b2a34709ac76d16133be34f3d0c87f93b8a diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 888ecdad43..30dc6b2a34 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: -- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),以及匹配目标平台的 ripgrep `-rg` sidecar。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),以及匹配目标平台的 ripgrep `-rg` 伴随文件。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk-runtime/package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 @@ -17,12 +17,12 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`, exe 缺失时抛出 `FileNotFoundError`,并写明两种获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。仅限开发的 node 载体缺失时只提示构建脚本这一条途径。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 -每个 wheel 包只包含一个运行时可执行文件及其匹配的 ripgrep `-rg` sidecar。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少任一 sidecar 都意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用文件系统搜索或 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在或存在多个运行时可执行文件、缺失或多余的 sidecar、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 +每个 wheel 包只包含一个运行时可执行文件及其匹配的 ripgrep `-rg` 伴随文件。macOS wheel 包还包含与其匹配的原生 spawn helper;缺少任一伴随文件都意味着该安装不完整,并会在启动时硬失败,即使所选 Cordis 组合不使用文件系统搜索或 PTY 工具也是如此。Linux wheel 包不包含 spawn helper,因为 `node-pty` 直接使用暂存的 `pty.node` 原生插件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_14_0_arm64`;macOS 标签保守匹配内置 Node 24 可执行文件的 macOS 13.5 部署目标。本包的 `platforms.json` 统一定义仓库发行构建器与隔离构建钩子使用的固定标签和可执行文件名。构建钩子会拒绝 `py3-none-any`、不存在或存在多个运行时可执行文件、缺失或多余的伴随文件、文件不可执行以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-v` 发布标签必须与其匹配。 ## 解析 API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 -- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体);它会在所有平台校验必要的 `-rg` sidecar,并在 macOS 上额外校验 `-spawn-helper` sidecar。node 载体没有单一路径的等价物,经由上面的 argv 元组启动。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体);它会在所有平台校验必要的 `-rg` 伴随文件,并在 macOS 上额外校验 `-spawn-helper` 伴随文件。node 载体没有单一路径的等价物,经由上面的 argv 元组启动。 - `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 - `bundled_package_dir() -> Path`——已安装包的数据根目录。 diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index b478efe5e0..f001adccb2 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -149,6 +149,7 @@ MCP_SERVER_SCRIPT = """\ import json import os import sys +import time log_path = os.environ.get("MCP_SMOKE_LOG") @@ -179,6 +180,10 @@ for line in sys.stdin: }, }) elif method == "tools/list": + # Keep discovery pending longer than the old smoke's 100 ms grace + # period. An SDK runtime that answers initialize too early will make + # its first model request without this tool and fail deterministically. + time.sleep(0.25) send({ "jsonrpc": "2.0", "id": request_id, @@ -255,20 +260,6 @@ def mcp_cordis(server_script: Path) -> str: ], indent=2) -def wait_for_mcp_discovery(log_path: Path) -> None: - """Wait until the external server has answered initial tool discovery.""" - deadline = time.monotonic() + 10 - while time.monotonic() < deadline: - if log_path.exists() and "tools/list" in log_path.read_text().splitlines(): - # The server records the request before flushing its response; give the - # client one scheduler interval to register the returned generation. - time.sleep(0.1) - return - time.sleep(0.025) - observed = log_path.read_text() if log_path.exists() else "" - raise AssertionError(f"packaged MCP client did not complete tool discovery: {observed}") - - class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -861,7 +852,6 @@ def smoke_sdk_mcp(base_url: str, executable: Path | None) -> None: base_url=base_url, request_timeout_seconds=60, ) as harness: - wait_for_mcp_discovery(discovery_log) result = harness.run(MCP_PROMPT, session_id="mcp-smoke") assert result.final_response == MCP_TEXT, result.final_response diff --git a/scripts/verify-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index 50d97e0dae..09a1b044ab 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -99,6 +99,23 @@ describe('verifyRuntimeClosure', () => { expect(result.failures).toEqual([]) }) + it('requires preset plugins to be linked from the workspace', async () => { + const root = fixture({ + 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: { '@scope/plugin': '1.2.3' } }, + 'python/sdk-runtime/platforms.json': platforms, + 'apps/cli/config/agent-presets/standard/agent.cordis.yml': ` +- id: plugin + name: '@scope/plugin' +`, + }) + + const result = await verifyRuntimeClosure(root) + + expect(result.failures).toEqual([ + 'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64)', + ]) + }) + it('fails when no shipped preset is discovered', async () => { const root = fixture({ 'python/sdk-runtime/package.json': { name: 'runtime', dependencies: {} }, diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts index 549c06e9ad..927bae8db5 100644 --- a/scripts/verify-runtime-closure.ts +++ b/scripts/verify-runtime-closure.ts @@ -133,9 +133,13 @@ async function missingPresetPlugins( for (const target of targets) { const processPlatform = processPlatformForTarget(target) for (const plugin of activeBarePluginPackages(document, processPlatform)) { - if (runtimeDependencies[plugin] !== undefined) continue + const version = runtimeDependencies[plugin] + if (version?.startsWith('workspace:') === true) continue const preset = basename(dirname(presetPath)) - const key = `${preset} preset -> ${plugin}` + const declaration = version === undefined + ? '' + : ` [runtime dependency is ${JSON.stringify(version)}; expected workspace:]` + const key = `${preset} preset -> ${plugin}${declaration}` const targets = missing.get(key) ?? new Set() targets.add(target) missing.set(key, targets) From aab839a971fcfd37dfff687f58ea13dbc79f3c2f Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 17:39:38 +0800 Subject: [PATCH 078/137] feat(web): Implement file-open failure handling in chat view --- ...08-18-tool-row-file-open-failure.i18n.yaml | 6 ++ .../2026-08-18-tool-row-file-open-failure.md | 33 +++++++ ...026-08-18-tool-row-file-open-failure.zh.md | 33 +++++++ ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 4 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 4 +- apps/web/tests/navigation-panes.e2e.ts | 15 ++- apps/web/tests/seeded-history.e2e.ts | 45 ++++++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 5 +- .../src/client/chat/ChatView.module.css | 12 +++ .../src/client/chat/ChatView.tsx | 80 +++++++++++++++- .../src/client/contract/slots.ts | 5 +- .../ui-conversation/src/client/locales.ts | 4 + .../tests/apply-inject.client.spec.tsx | 8 ++ .../tests/chat-view.client.spec.tsx | 95 ++++++++++++++++++- 18 files changed, 333 insertions(+), 28 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml new file mode 100644 index 0000000000..0e7aafe5bc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.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-18-tool-row-file-open-failure.md +2026-08-18-tool-row-file-open-failure.md: 8b54fe323f1ea9dba302a9cb8cbf10944ac7ea3f +2026-08-18-tool-row-file-open-failure.zh.md: a969905729b6cccb510ca85bcc698ddbc33ccb54 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md new file mode 100644 index 0000000000..8b54fe323f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md @@ -0,0 +1,33 @@ +# Agent Note: Tool-row file-open failures stay visible + +Status: implemented + +English | [中文](2026-08-18-tool-row-file-open-failure.zh.md) + +## Problem + +Tool-row path clicks already call `host.openPath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try. + +The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.md) still owns the link gesture and the Host handoff. This note owns only the refusal. + +## Decision + +The inject returns the `workspaces.openPath` promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or `fileOpen.unknown` when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog. + +The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. + +The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path open failed: ` onto the wire error; the dialog does not unwrap that prefix. + +## Alternatives considered + +- **Per-row inline error.** The Host call is conversation-owned and several entries share one opener; a row-local banner would duplicate the same refusal next to every click target. +- **Toast without retry.** The product ask is the reason *and* a retry entry. The workspace folder-adoption dialog already pairs those two. +- **Chat-store persistence.** A failed open is transient view state. The chat store survives reload, so a leftover dialog would return after a refresh that cannot usefully retry the original gesture. + +## Consequences + +A silent Host refusal is no longer a success from the reader's seat. Headless or remote deployments that click a path now see why the desktop handoff did not happen. The view holds one extra request-generation counter so dismiss and retry stay race-safe. + +## Testing + +Package specs cover inject rejection, the dialog copy (Error, non-Error, empty), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row and asserts the English reason plus a second call with the same payload. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md new file mode 100644 index 0000000000..a969905729 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Tool-row file-open failures stay visible + +Status: implemented + +[English](2026-08-18-tool-row-file-open-failure.md) | 中文 + +## 问题 + +工具行路径点击已经通过聊天视图注入的 `openFile` 调用 `host.openPath`。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。 + +[用系统应用打开文件的决策](../feature/2026-07-28-tool-call-file-open-in-os.md) 仍然拥有链接手势和 Host 交接。本 Agent Note 只拥有拒绝路径。 + +## 决策 + +inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用 `fileOpen.unknown`),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。 + +对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。 + +Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。 + +## 考虑过的替代方案 + +- **按行内联错误。** Host 调用由会话拥有,多个入口共用一个打开器;行内横幅会在每个点击目标旁重复同一拒绝。 +- **没有重试的 toast。** 产品要求同时给出原因和重试入口。工作区文件夹采纳对话框已经把这两者配对。 +- **写入 chat store 并持久化。** 打开失败是瞬时视图状态。chat store 会在刷新后存活,于是残留对话框会在无法有效重试原手势的刷新之后回来。 + +## 后果 + +从读者一侧看,静默的 Host 拒绝不再等同于成功。无头或远程部署点击路径时,能看到桌面交接为何没有发生。视图多持有一个请求世代计数器,使关闭与重试在竞态下仍然安全。 + +## 测试 + +包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,并断言英文原因以及对同一 payload 的第二次调用。 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 5e378099c9..393f7c17b9 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: d78cf4da2c1861a66a0cefb24dba785c7cb1279d -2026-07-28-tool-call-file-open-in-os.zh.md: c13f6e60c0c70a3036f50c678c7b348c91f874c3 +2026-07-28-tool-call-file-open-in-os.md: 08fc51cc3a5d2fb43b67dc158fd1ee789fafdb68 +2026-07-28-tool-call-file-open-in-os.zh.md: 59079533b502d234bafb0b53c123e5895e105833 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index d78cf4da2c..08fc51cc3a 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -23,9 +23,9 @@ File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `fil ## Consequences -Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. +Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). Remote or non-loopback clients cannot invoke `host.openPath`. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)). ## Risks -- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat row stays silent while the host returns an internal error. +- Desktop Linux hosts without `xdg-open`, and WSL hosts without working Windows interop (`wslpath` plus `powershell.exe`), fail the RPC; the chat view shows that Host error and offers retry. - Relative paths without a session cwd are forwarded verbatim and may fail on the host. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index c13f6e60c0..59079533b5 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -23,9 +23,9 @@ Status: implemented ## 后果 -点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。 +点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。远程或非回环客户端无法调用 `host.openPath`。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.md))。 ## 风险 -- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天行保持静默,宿主返回内部错误。 +- 没有 `xdg-open` 的桌面 Linux 宿主,以及 Windows 互操作(`wslpath` 加 `powershell.exe`)不可用的 WSL 宿主,会使 RPC 失败;聊天视图展示该 Host 错误并提供重试。 - 没有会话 cwd 时相对路径会原样转发,可能在宿主侧失败。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index a0b34e53b9..1e2a0953b2 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -12,7 +12,7 @@ import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' import { strFromU8, unzipSync } from 'fflate' -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -415,8 +415,17 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // Read summaries are host-open file links; they also must not open details. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { ok: true, value: { opened: true as const } }, + })) + try { + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + } finally { + openPath.mockRestore() + } }, 60_000) it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => { diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 20ad261c0d..dd3ddf1308 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -14,7 +14,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' @@ -396,12 +396,51 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.waitFor({ timeout: 10_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') - await fileLink.click() - await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { ok: true, value: { opened: true as const } }, + })) + try { + await fileLink.click() + await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') + } finally { + openPath.mockRestore() + } // Path label survives from the recorded args (a.txt). await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) }) + it.skipIf(MODE === 'record')('a Host open refusal keeps the reason and retries the same path', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-file-open-failure')) + const fileLink = page.locator('[data-variant="read"] button').first() + await fileLink.waitFor({ timeout: 10_000 }) + const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') + .mockImplementation(async (request, _signal) => ({ + rpcId: request.rpcId, + result: { + ok: false as const, + error: { code: 'internal', message: 'xdg-open is not available', details: {} }, + }, + })) + try { + await fileLink.click() + const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) + await dialog.waitFor({ timeout: 5_000 }) + await expect.poll(() => dialog.getByRole('alert').innerText(), { timeout: 5_000 }) + .toBe('path open failed: xdg-open is not available') + await page.getByRole('button', { name: 'Retry' }).click() + await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) + expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload) + await page.getByRole('button', { name: 'Cancel' }).click() + await expect.poll(() => page.getByRole('dialog', { name: 'Couldn’t open file' }).count(), { + timeout: 5_000, + }).toBe(0) + } finally { + openPath.mockRestore() + } + }) + it.skipIf(MODE === 'record')('expands the cold-resumed compact summary', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction')) const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6d866e05e7..454e0fac85 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: d1a265b5789d9f1d9b5e630e0548ae5f619eebbf -README.zh.md: 3f303391d39bc040b4a6a5a2d1f6a34fe8891919 +README.md: 6dd51df7a5c76ba8085f3a7816fc91927364eef6 +README.zh.md: 7830ae193d7f3e2dcf0f681ea7714cc51eb86299 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d1a265b578..6dd51df7a5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -22,7 +22,7 @@ Logged non-user messages render as a default-collapsed disclosure whose header n A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). -The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. +The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent. A path click through the injected `openFile` asks the Host to open that path (relative paths resolve against the session cwd). A Host or OS refusal opens an in-page dialog with the thrown reason and a Retry of the same path; Cancel, Escape, the close control, and a mask click dismiss it ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md)). The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3f303391d3..7830ae193d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -20,7 +20,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 -聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。 +聊天视图保留工具的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染运行时已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。经注入的 `openFile` 点击路径会请 Host 打开该路径(相对路径按会话 cwd 解析)。Host 或操作系统拒绝时,页面内对话框展示抛出的原因,并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框([决策](../../../.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md))。 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index f57caea9e5..1397570bf3 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -394,10 +394,7 @@ export function apply(ctx: Context): void { fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner), openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd - void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => { - // Host/OS open failures stay silent in the chat row; the native - // app surfaces its own error dialog when the path is unusable. - }) + return workspaces.openPath(resolveWorkspacePath(cwd, path)) }, loadOlder: () => { void scoped.loadOlder() }, loadImage: attachment => conversation.resolveImage(sessionId, attachment), diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index d16608c856..32f9f38da7 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -190,3 +190,15 @@ .toBottom:hover { background: var(--dsw-alias-button-floating-hover); } + +/* Host open-path refusal: same dialog family as the workspace folder error. */ +.modalAction { + min-width: 72px; +} + +.modalError { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 58e63b312f..fd1e466ca1 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -1,7 +1,8 @@ // ChatView: the default conversation view — one stable keyed parent list over // final business Nodes, plus paging, pending steering and bottom-follow. // Each row dispatches through 'conversation.chat.node'; ui-tool owns the -// tool-call renderer and its recursive root/subcall composition. +// tool-call renderer and its recursive root/subcall composition. A Host +// open-path refusal from the injected opener is an in-page dialog here. // // Scroll: when nested under `[data-conversation-scroll]` (active conversation // column), that host is the scrollport and this view is flow content; when @@ -12,9 +13,9 @@ // ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool // lifecycle updates replace only their own row without remounting it. -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client' -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' @@ -95,6 +96,12 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP } } +/** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */ +function openFailureMessage(error: unknown, fallback: string): string { + const message = error instanceof Error ? error.message : String(error) + return message === '' ? fallback : message +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -159,6 +166,34 @@ export function ChatView({ const hasMore = useSession(s => s.hasMore) const loadingOlder = useSession(s => s.loadingOlder) const selectedCallId = useStore(s => s.selection?.callId) + const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) + const [fileOpenBusy, setFileOpenBusy] = useState(false) + // Close/retry must ignore a settlement that started before the latest + // gesture; otherwise a cancelled in-flight refusal reopens the dialog. + const fileOpenRequest = useRef(0) + + const requestOpenFile = useCallback((path: string) => { + const id = ++fileOpenRequest.current + setFileOpenBusy(true) + void Promise.resolve(openFile(path)).then( + () => { + if (id !== fileOpenRequest.current) return + setFileOpenError(null) + setFileOpenBusy(false) + }, + (error: unknown) => { + if (id !== fileOpenRequest.current) return + setFileOpenError({ path, message: openFailureMessage(error, t('fileOpen.unknown')) }) + setFileOpenBusy(false) + }, + ) + }, [openFile, t]) + + const closeFileOpenError = useCallback(() => { + fileOpenRequest.current += 1 + setFileOpenError(null) + setFileOpenBusy(false) + }, []) const pendingSteering = useMemo( () => inbox.filter(item => item.placement === 'steering'), @@ -386,7 +421,7 @@ export function ChatView({ useSession={useSession} selectedCallId={selectedCallId} cwd={cwd} - openFile={openFile} + openFile={requestOpenFile} inspectCall={inspectCall} forkAt={forkAt} loadImage={loadImage} @@ -422,6 +457,43 @@ export function ChatView({ )} + {fileOpenError !== null && ( + { requestOpenFile(fileOpenError.path) }} + t={t} + /> + )} ) } + +/** In-page Host open-path refusal: the wire reason plus a retry of the same path. */ +function FileOpenErrorDialog({ + message, busy, onClose, onRetry, t, +}: { + message: string + busy: boolean + onClose: () => void + onRetry: () => void + t: ChatViewSlotProps['t'] +}) { + return ( + + + + + )} + > +

{message}
+ + ) +} diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 21259146a7..4e4522052a 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -677,9 +677,10 @@ export interface ChatViewInjected { openDetails: (target: SelectionTarget) => void /** * Open a tool-arg filesystem path with the host OS default application - * (relative paths resolve against the session cwd). + * (relative paths resolve against the session cwd). Rejects when the Host + * cannot hand the path off; the chat view shows that reason and a retry. */ - openFile: (path: string) => void + openFile: (path: string) => void | Promise loadOlder: () => void /** Resolve a session-authorized historical image for inline display. */ loadImage: (attachment: ImageAttachmentRef) => Promise diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index c9b6f658ca..a441ac044c 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -91,6 +91,8 @@ export const zh = { 'chat.loadError': '历史加载失败:{message}({code})', 'chat.loadOlder': '加载更早', 'chat.toBottom': '回到底部', + 'fileOpen.title': '无法打开文件', + 'fileOpen.unknown': '无法打开此文件', 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', @@ -260,6 +262,8 @@ export const en = { 'chat.loadError': 'Failed to load history: {message} ({code})', 'chat.loadOlder': 'Load earlier', 'chat.toBottom': 'Back to bottom', + 'fileOpen.title': 'Couldn’t open file', + 'fileOpen.unknown': 'Couldn’t open this file', 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 9cec01789a..38801e486a 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -240,6 +240,14 @@ describe('conversation slot inject API', () => { await b.runtime.dispose() }) + it('openFile rejects when the Host cannot open the path', async () => { + const b = await bench() + b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available'))) + const { injected } = b.chatViewApi(ROOT) + await expect(Promise.resolve(injected.openFile('src/a.ts'))).rejects.toThrow('xdg-open is not available') + await b.runtime.dispose() + }) + it('routes workspace switching through the runtime owner, carrying the draft', async () => { const b = await bench() const resident = b.residentApi(ROOT) diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index 360b589bdc..6ca09a997e 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -4,7 +4,7 @@ // ObservableSnapshot fake, no wire or Tool presentation plugin. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, within } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { useEffect } from 'react' import type { AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, @@ -967,10 +967,101 @@ describe('ChatView', () => { }) const owner = calls[0]?.owner as RoutedChatNodeOwner expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block) - expect(owner.openFile).toBe(h.openFile) + expect(owner.openFile).not.toBe(h.openFile) + owner.openFile('src/a.ts') + expect(h.openFile).toHaveBeenCalledWith('src/a.ts') expect(owner.inspectCall).toBe(h.inspectCall) }) + it('shows a Host open refusal with the reason and retries the same path', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('xdg-open is not available')) + .mockResolvedValueOnce(undefined) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件' })).toBeTruthy() + }) + expect(screen.getByRole('alert').textContent).toBe('xdg-open is not available') + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull() + }) + expect(openFile).toHaveBeenCalledTimes(2) + expect(openFile).toHaveBeenNthCalledWith(1, 'src/a.ts') + expect(openFile).toHaveBeenNthCalledWith(2, 'src/a.ts') + }) + + it('keeps a non-Error Host refusal visible and dismisses it on cancel', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce('permission denied') + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('notes.md') }) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('permission denied') + }) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(openFile).toHaveBeenCalledTimes(1) + }) + + it('substitutes the unknown-open copy when the Host refusal has no text', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('')) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('empty.ts') }) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('无法打开此文件') + }) + }) + + it('ignores a Host refusal that settles after the dialog is dismissed', async () => { + let rejectRetry!: (error: unknown) => void + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('first refusal')) + .mockImplementationOnce(() => new Promise((_resolve, reject) => { + rejectRetry = reject + })) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('first refusal') + }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + expect(screen.queryByRole('dialog')).toBeNull() + await act(async () => { rejectRetry(new Error('late refusal')) }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('ignores a Host open that succeeds after the dialog is dismissed', async () => { + let resolveRetry!: () => void + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('first refusal')) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveRetry = () => { resolve() } + })) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) + await waitFor(() => { + expect(screen.getByRole('alert').textContent).toBe('first refusal') + }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) + fireEvent.click(screen.getByRole('button', { name: '取消' })) + await act(async () => { resolveRetry() }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + it('prepend preserves a semantic row; a trailing user node force-scrolls', () => { const h = makeHarness({ nodes: [user(5, 'later'), assistant(6, 'a')], hasMore: true }) const view = render() From 1b9f9ae256e469eea3aa8ae1a0fb95a6b426a373 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 18 Aug 2026 18:02:02 +0800 Subject: [PATCH 079/137] fix(ci): install local Python release wheels --- .github/workflows/build-exe-for-python-sdk.yml | 16 +++++++++------- .github/workflows/python-release.yml | 8 ++++++-- python/development.i18n.yaml | 4 ++-- python/development.md | 12 ++++++++++-- python/development.zh.md | 12 ++++++++++-- scripts/ci-workflow.spec.ts | 14 ++++++++++++-- 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 5fc6de459d..02c04257b0 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -261,15 +261,16 @@ jobs: name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python - - name: Install only the SDK into a clean venv and run zero-config + - name: Install local SDK and runtime wheels into a clean venv env: - VERSION: ${{ needs.plan.outputs.version }} + RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }} + SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl run: | set -euo pipefail python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ - --find-links dist-python \ - deepseek-harness-sdk=="$VERSION" + "dist-python/$SDK_WHEEL" \ + "dist-python/$RUNTIME_WHEEL" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ --scenario sdk-default "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ @@ -299,7 +300,8 @@ jobs: if: runner.os == 'Linux' env: RUNNER_ARCH: ${{ runner.arch }} - VERSION: ${{ needs.plan.outputs.version }} + RUNTIME_WHEEL: ${{ steps.runtime.outputs.wheel }} + SDK_WHEEL: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl run: | set -euo pipefail case "$RUNNER_ARCH" in @@ -307,9 +309,9 @@ jobs: ARM64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; esac - docker run --rm -e VERSION -e DSH_TELEMETRY_DISABLED -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' + docker run --rm -e RUNTIME_WHEEL -e SDK_WHEEL -e DSH_TELEMETRY_DISABLED -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk - /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION" + /tmp/dsh-sdk/bin/python -m pip install "/work/dist-python/$SDK_WHEEL" "/work/dist-python/$RUNTIME_WHEEL" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp ' diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index a395a8e8bd..f5b9c63c4b 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -68,9 +68,13 @@ jobs: print(f"version={release['pep440_version'](repository_version)}") PY - - name: Install and run the published entry path + - name: Install local release wheels and run the public entry path + env: + VERSION: ${{ steps.compatibility-version.outputs.version }} run: | - python -m pip install --find-links dist "deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}" + python -m pip install \ + "dist/deepseek_harness_sdk-$VERSION-py3-none-any.whl" \ + "dist/deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl" python scripts/smoke-python-runtime.py --scenario sdk-default python scripts/smoke-python-runtime.py --scenario sdk-mcp diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index 72df1143fc..a9cdaa8daa 100644 --- a/python/development.i18n.yaml +++ b/python/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 python/development.md -development.md: fe62a109f2643afe0b9be1ed51b86be0b9fa731f -development.zh.md: d4ab9850d6c83dc17a740240f97cef89d61faaa0 +development.md: 617d030294dafa51aea513adb811bb5f377431c9 +development.zh.md: 2049ad12856c4107788aec168e3724965cebd5af diff --git a/python/development.md b/python/development.md index fe62a109f2..617d030294 100644 --- a/python/development.md +++ b/python/development.md @@ -61,10 +61,18 @@ The root `package.json` version is authoritative for both Python distributions. Build the pure SDK wheel once and one runtime wheel on each native platform: ```sh -version="$(node -p "require('./package.json').version")" +version="$(python - <<'PY' +import runpy + +release = runpy.run_path("scripts/build-python-release.py") +print(release["pep440_version"](release["repository_version"]())) +PY +)" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness-sdk=="$version" +pip install \ + "dist-python/deepseek_harness_sdk-$version-py3-none-any.whl" \ + "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" ``` The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS 14 or newer on arm64. A `python-v` tag is accepted only when it matches the repository version; prerelease repository versions such as `0.0.1-rc.1` use their normalized PEP 440 spelling, such as `0.0.1rc1`, inside wheel filenames and metadata. diff --git a/python/development.zh.md b/python/development.zh.md index d4ab9850d6..2049ad1285 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -61,10 +61,18 @@ with DeepSeekHarness() as harness: 纯 SDK wheel 包只需构建一次;每个原生平台分别构建一个运行时 wheel 包: ```sh -version="$(node -p "require('./package.json').version")" +version="$(python - <<'PY' +import runpy + +release = runpy.run_path("scripts/build-python-release.py") +print(release["pep440_version"](release["repository_version"]())) +PY +)" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness-sdk=="$version" +pip install \ + "dist-python/deepseek_harness_sdk-$version-py3-none-any.whl" \ + "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" ``` 运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS 14 或更高版本的 arm64。只有与仓库版本匹配时,才接受 `python-v` 标签;`0.0.1-rc.1` 之类的仓库预发布版本在 wheel 包文件名和元数据中使用规范化的 PEP 440 写法,例如 `0.0.1rc1`。 diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 45712860e5..9c0f56c85a 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -268,7 +268,10 @@ describe('Python release workflows', () => { }, }) expect(pythonCompat.strategy).toMatchObject({ matrix: { python: ['3.10', '3.14'] } }) - expect(JSON.stringify(pythonCompat.steps)).toContain('deepseek-harness-sdk==${{ steps.compatibility-version.outputs.version }}') + const pythonCompatSteps = JSON.stringify(pythonCompat.steps) + expect(pythonCompatSteps).toContain('dist/deepseek_harness_sdk-$VERSION-py3-none-any.whl') + expect(pythonCompatSteps).toContain('dist/deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl') + expect(pythonCompatSteps).not.toContain('--find-links') const validateSteps = JSON.stringify(validate.steps) const authorize = validate.steps.filter(isRecord).find(step => step.name === 'Authorize publication request') if (!isRecord(authorize) || typeof authorize.run !== 'string') { @@ -341,7 +344,14 @@ describe('Python release workflows', () => { expect(plan.if).toContain('inputs.ci') expect(plan.if).toContain('inputs.release') expect(JSON.stringify(plan.steps)).toContain('pep440_version') - expect(JSON.stringify(workflow)).toContain('macosx_14_0_arm64') + const workflowJson = JSON.stringify(workflow) + expect(workflowJson).toContain('macosx_14_0_arm64') + expect(workflowJson).toContain('dist-python/$SDK_WHEEL') + expect(workflowJson).toContain('dist-python/$RUNTIME_WHEEL') + expect(workflowJson).toContain('/work/dist-python/$SDK_WHEEL') + expect(workflowJson).toContain('/work/dist-python/$RUNTIME_WHEEL') + expect(workflowJson).not.toContain('--find-links dist-python') + expect(workflowJson).not.toContain('--find-links /work/dist-python') expect(manylinuxAddon).toMatchObject({ if: "runner.os == 'Linux'" }) expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_x86_64') expect(JSON.stringify(manylinuxAddon)).toContain('manylinux_2_28_aarch64') From a3fc0b5ecdbe02a3dda42e0b93f4965654902308 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 18:06:06 +0800 Subject: [PATCH 080/137] test(subagent): use platform Codex argv --- packages/subagent/subagent-codex/tests/subagent-codex.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index e3864d0a19..d0157acbdf 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1957,7 +1957,7 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ - argv: ['codex', 'app-server', '--stdio'], + argv: codexAppServerArgv(), env: { OPENAI_API_KEY: 'fake' }, graceMs: 25, cwd: process.cwd(), From b1be9e93cd677cddc19f0e39d71340e4f951f77c Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 18:54:18 +0800 Subject: [PATCH 081/137] fix: optimize ui --- apps/web/tests/seeded-history.e2e.ts | 3 +-- .../src/client/chat/ChatView.module.css | 7 ------- .../src/client/chat/ChatView.tsx | 21 +++++++++++++++---- .../tests/chat-view.client.spec.tsx | 10 ++++----- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index dd3ddf1308..6b4769a916 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -427,8 +427,7 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.click() const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) await dialog.waitFor({ timeout: 5_000 }) - await expect.poll(() => dialog.getByRole('alert').innerText(), { timeout: 5_000 }) - .toBe('path open failed: xdg-open is not available') + await expect(dialog.getByText('path open failed: xdg-open is not available')).toBeVisible({ timeout: 5_000 }) await page.getByRole('button', { name: 'Retry' }).click() await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 32f9f38da7..ffee12984f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -195,10 +195,3 @@ .modalAction { min-width: 72px; } - -.modalError { - margin-top: 8px; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-state-error-primary); -} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index fd1e466ca1..48646fb3be 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -102,6 +102,20 @@ function openFailureMessage(error: unknown, fallback: string): string { return message === '' ? fallback : message } +/** + * Local demo only: `?openFileFail=1` randomly refuses (or lets the Host + * open succeed) so the three dialog cases can be clicked through. + */ +function demoOpenFailure(): Promise | undefined { + if (typeof location === 'undefined') return undefined + if (!new URLSearchParams(location.search).has('openFileFail')) return undefined + const pick = Math.floor(Math.random() * 4) + if (pick === 0) return Promise.reject(new Error('xdg-open is not available')) + if (pick === 1) return Promise.reject('permission denied') + if (pick === 2) return Promise.reject(new Error('')) + return undefined +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -175,7 +189,7 @@ export function ChatView({ const requestOpenFile = useCallback((path: string) => { const id = ++fileOpenRequest.current setFileOpenBusy(true) - void Promise.resolve(openFile(path)).then( + void Promise.resolve(demoOpenFailure() ?? openFile(path)).then( () => { if (id !== fileOpenRequest.current) return setFileOpenError(null) @@ -486,14 +500,13 @@ function FileOpenErrorDialog({ onClose={onClose} closeLabel={t('close')} title={t('fileOpen.title')} + description={message} footer={( <> )} - > -
{message}
- + /> ) } diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index 6ca09a997e..a12368d12a 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -984,7 +984,7 @@ describe('ChatView', () => { await waitFor(() => { expect(screen.getByRole('dialog', { name: '无法打开文件' })).toBeTruthy() }) - expect(screen.getByRole('alert').textContent).toBe('xdg-open is not available') + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('xdg-open is not available') await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() @@ -1002,7 +1002,7 @@ describe('ChatView', () => { render() await act(async () => { h.toolOwners[0]!.openFile('notes.md') }) await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('permission denied') + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('permission denied') }) fireEvent.click(screen.getByRole('button', { name: '取消' })) expect(screen.queryByRole('dialog')).toBeNull() @@ -1017,7 +1017,7 @@ describe('ChatView', () => { render() await act(async () => { h.toolOwners[0]!.openFile('empty.ts') }) await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('无法打开此文件') + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('无法打开此文件') }) }) @@ -1033,7 +1033,7 @@ describe('ChatView', () => { render() await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('first refusal') + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal') }) await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) fireEvent.click(screen.getByRole('button', { name: '取消' })) @@ -1054,7 +1054,7 @@ describe('ChatView', () => { render() await act(async () => { h.toolOwners[0]!.openFile('src/a.ts') }) await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('first refusal') + expect(screen.getByRole('dialog', { name: '无法打开文件' }).textContent).toContain('first refusal') }) await act(async () => { fireEvent.click(screen.getByRole('button', { name: '重试' })) }) fireEvent.click(screen.getByRole('button', { name: '取消' })) From 4037762dc0bbc3e671045e729868768320a5eafd Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 18:57:37 +0800 Subject: [PATCH 082/137] fix: build --- apps/web/tests/seeded-history.e2e.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 6b4769a916..ec12ddfeaf 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -427,7 +427,8 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.click() const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) await dialog.waitFor({ timeout: 5_000 }) - await expect(dialog.getByText('path open failed: xdg-open is not available')).toBeVisible({ timeout: 5_000 }) + await expect.poll(() => dialog.innerText(), { timeout: 5_000 }) + .toContain('path open failed: xdg-open is not available') await page.getByRole('button', { name: 'Retry' }).click() await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload) From e3752e207ee898189d11d230ce094e85830b4dbb Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:02:38 +0800 Subject: [PATCH 083/137] fix: remove debug code --- .../ui-conversation/src/client/chat/ChatView.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 48646fb3be..e686b879f0 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -102,20 +102,6 @@ function openFailureMessage(error: unknown, fallback: string): string { return message === '' ? fallback : message } -/** - * Local demo only: `?openFileFail=1` randomly refuses (or lets the Host - * open succeed) so the three dialog cases can be clicked through. - */ -function demoOpenFailure(): Promise | undefined { - if (typeof location === 'undefined') return undefined - if (!new URLSearchParams(location.search).has('openFileFail')) return undefined - const pick = Math.floor(Math.random() * 4) - if (pick === 0) return Promise.reject(new Error('xdg-open is not available')) - if (pick === 1) return Promise.reject('permission denied') - if (pick === 2) return Promise.reject(new Error('')) - return undefined -} - function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -189,7 +175,7 @@ export function ChatView({ const requestOpenFile = useCallback((path: string) => { const id = ++fileOpenRequest.current setFileOpenBusy(true) - void Promise.resolve(demoOpenFailure() ?? openFile(path)).then( + void Promise.resolve(openFile(path)).then( () => { if (id !== fileOpenRequest.current) return setFileOpenError(null) From 6ebf8d199d35bcbfa7a1648ac6ceb7123e4c28c3 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 19:14:02 +0800 Subject: [PATCH 084/137] fix(subagent): preserve Codex process exit facts --- packages/subagent/subagent-codex/src/run.ts | 90 +++++----- packages/subagent/subagent-codex/src/wire.ts | 19 +- .../subagent-codex/tests/real-product.spec.ts | 44 ++++- .../tests/subagent-codex.spec.ts | 167 +++++++++--------- 4 files changed, 178 insertions(+), 142 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 6265587fab..61cfee871e 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -164,13 +164,7 @@ export async function disposeCodexChild( wire: CodexAppServerWire, child: SubprocessHandle, ): Promise { - const failures: Error[] = [] - let outcome: SubprocessOutcome | undefined - try { - wire.close() - } catch (error: unknown) { - failures.push(thrown(error)) - } + wire.close() if (child.pid > 0) { try { @@ -182,31 +176,17 @@ export async function disposeCodexChild( try { await child.waitForExit() } catch (error: unknown) { - failures.push(thrown(error)) - } - try { - outcome = await child.done - } catch (error: unknown) { - failures.push(thrown(error)) + const outcome = await child.done + throw new CodexRunFailure({ + stage: 'teardown', + category: 'unknown', + outcome, + }, thrown(error)) } + await child.done } else { await child.done.catch(() => {}) } - - const firstFailure = failures[0] - if (firstFailure === undefined) return - const facts = { - stage: 'teardown', - category: 'unknown', - outcome, - } as const - if (failures.length === 1) { - throw new CodexRunFailure(facts, firstFailure) - } - throw new AggregateError( - failures.map(failure => new CodexRunFailure(facts, failure)), - `subagent-codex: ${failureDiagnostic(facts)}`, - ) } /** @@ -271,16 +251,23 @@ export async function startCodexRun( } } - const processFailure: Promise = child.done.then( - outcome => Promise.reject(new CodexRunFailure({ - stage: 'process', - category: 'process-exit', - outcome, - })), - (error: unknown) => Promise.reject(new CodexRunFailure({ - stage: 'process', - category: 'unknown', - }, thrown(error))), + let processFailureFacts: CodexFailureFacts | undefined + const processFailure: Promise = child.done.then( + (outcome) => { + processFailureFacts = { + stage: 'process', + category: 'process-exit', + outcome, + } + throw new CodexRunFailure(processFailureFacts) + }, + (error: unknown) => { + processFailureFacts = { + stage: 'process', + category: 'unknown', + } + throw new CodexRunFailure(processFailureFacts, thrown(error)) + }, ) // A normal post-result dispose also closes the process. Keep that expected // late rejection observed after the result race has already settled. @@ -349,19 +336,32 @@ export async function startCodexRun( processFailure, ]) if (terminal.stopReason === 'completed') return terminal - const facts = wire.collectFailure() ?? { - stage: 'turn', - category: 'unknown', - } + const facts = wire.collectFailure() return { ...terminal, diagnostic: recordFailureDiagnostic(facts) } } catch (error: unknown) { // Give stderr data already queued in Node one turn to reach the wire - // before settlement snapshots the diagnostic; later OS data is best-effort. + // before settlement snapshots the diagnostic. await new Promise((resolve) => { setImmediate(resolve) }) - const wireFacts = wire.collectFailure() + const endedBeforeTerminal = wire.endedBeforeTerminal() + if ( + endedBeforeTerminal + && processFailureFacts === undefined + && !runAbort.signal.aborted + ) { + try { + const exited = await child.waitForExit( + AbortSignal.timeout(spec.disposeGraceMs), + ) + if (exited) await child.done + } catch { + // The wire failure remains authoritative when exit observation fails. + } + } const facts = error instanceof CodexRunFailure ? error.facts - : wireFacts ?? { stage: 'turn', category: 'unknown' } + : endedBeforeTerminal && processFailureFacts !== undefined + ? processFailureFacts + : wire.collectFailure() recordFailureDiagnostic(facts) throw error instanceof CodexRunFailure ? error diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 9cd6b4f049..cbc0504e08 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -237,6 +237,8 @@ export class CodexAppServerWire { readonly reason: string } | undefined private stderrTail = '' + private inputEnded = false + private terminalObserved = false private closed = false constructor( @@ -270,6 +272,14 @@ export class CodexAppServerWire { this.transport.start() } + /** + * Whether protocol output ended before a terminal turn notification. + * @returns `true` only for an early protocol close without a terminal turn. + */ + endedBeforeTerminal(): boolean { + return this.inputEnded && !this.terminalObserved + } + /** * Perform the required app-server initialize/initialized handshake. * @param signal - unpublished-start cancellation. @@ -415,10 +425,11 @@ export class CodexAppServerWire { /** * The structured failure fact observed for this published turn. - * @returns a fixed stage/category pair and optional HTTP status. + * Call only after a non-completed return or rejection from {@link runTurn}. + * @returns the fixed stage/category pair and optional HTTP status. */ - collectFailure(): CodexWireFailureFacts | undefined { - return this.failure + collectFailure(): CodexWireFailureFacts { + return this.failure as CodexWireFailureFacts } /** @@ -469,6 +480,7 @@ export class CodexAppServerWire { } private readonly onInputEnd = (): void => { + this.inputEnded = true this.fail(new Error('subagent-codex: app-server protocol stream closed')) } @@ -720,6 +732,7 @@ export class CodexAppServerWire { return } if (id !== this.turnId) return + this.terminalObserved = true if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) { throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`) } diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index 6b5bdb6793..bd48478336 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -15,7 +15,10 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import type { + SubprocessHandle, + SubprocessOutcome, +} from '@deepseek-ai/dsh-subprocess' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import * as codex from '../src/index.ts' import type { CodexPermissionMode } from '../src/run.ts' @@ -132,6 +135,26 @@ async function expectQuiescent(handles: readonly SubprocessHandle[]): Promise + readonly required?: string[] + readonly type?: string | string[] +} + function responseInputTexts(body: Record): string[] { if (!Array.isArray(body.input)) return [] return body.input.flatMap((item): string[] => { @@ -175,10 +198,7 @@ describe('real @openai/codex 0.147.0 product', () => { )) as { definitions: { CodexErrorInfo: { - oneOf: Array<{ - enum?: string[] - properties?: Record - }> + oneOf: JsonSchemaNode[] } } } @@ -203,6 +223,16 @@ describe('real @openai/codex 0.147.0 product', () => { 'responseTooManyFailedAttempts', 'activeTurnNotSteerable', ]) + for (const variant of schema.definitions.CodexErrorInfo.oneOf.slice(1, 5)) { + const category = Object.keys(variant.properties ?? {})[0]! + const detail = variant.properties?.[category] + expect(detail?.required).toBeUndefined() + expect(detail?.properties?.httpStatusCode).toEqual({ + format: 'uint16', + minimum: 0, + type: ['integer', 'null'], + }) + } const run = await harness.ctx.subagents.start('codex', { prompt: [{ type: 'text', text: task }], @@ -323,10 +353,10 @@ describe('real @openai/codex 0.147.0 product', () => { await fixture.requestStarted expect(harness.handles).toHaveLength(1) harness.handles[0]!.terminate() - await harness.handles[0]!.done + const outcome = await harness.handles[0]!.done await expect(run.result).resolves.toEqual({ output: [], - diagnostic: 'Product subagent failure (product: Codex; stage: turn; category: unknown)', + diagnostic: expectedProcessExitDiagnostic(outcome), stopReason: 'error', }) await run.dispose() diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index d0157acbdf..cc46215cca 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1479,50 +1479,6 @@ describe('run lifecycle and quiescence', () => { } }) - it('uses safe unknown fallbacks when the wire supplies no failure fact', async () => { - { - const collectFailure = vi.spyOn( - CodexAppServerWire.prototype, - 'collectFailure', - ).mockReturnValue(undefined) - const { child, run, turnStart } = await publishRun() - child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { - codexErrorInfo: 'contextWindowExceeded', - })) - await expect(run.result).resolves.toEqual({ - output: [], - diagnostic: expectedFailureDiagnostic('turn', 'unknown'), - stopReason: 'max-tokens', - }) - collectFailure.mockRestore() - await run.dispose() - } - { - const runTurn = vi.spyOn(CodexAppServerWire.prototype, 'runTurn') - .mockRejectedValueOnce(new Error('SECRET_TOKEN wire failure')) - const child = fakeChild() - const starting = startCodexRun(request(), runSpec(child)) - const initialize = await child.peer.nextMethod('initialize') - child.peer.respond(initialize, { userAgent: 'codex-cli 0.147.0' }) - await child.peer.nextMethod('initialized') - const threadStart = await child.peer.nextMethod('thread/start') - child.peer.respond(threadStart, { - thread: { id: 'thread-1', ephemeral: true }, - }) - const run = await starting - const result = await run.result - expect(result).toEqual({ - output: [], - diagnostic: expectedFailureDiagnostic('turn', 'unknown'), - stopReason: 'error', - }) - expect(result.diagnostic).not.toContain('SECRET_TOKEN') - runTurn.mockRestore() - await run.dispose() - } - }) - it('flattens child exit and protocol failures after publication', async () => { const errors: string[] = [] const outcomes: SubprocessOutcome[] = [ @@ -1549,9 +1505,45 @@ describe('run lifecycle and quiescence', () => { ) await run.dispose().catch(() => {}) } + { + const outcome = { exitCode: 17, signal: 'SIGABRT' } as const + const child = fakeChild({ exitOnTerminate: false }) + const { run, turnStart } = await publishRun(child, undefined, { + disposeGraceMs: 100, + }) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.fromChild.emit('end') + setTimeout(() => { child.settle(outcome) }, 5) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('process', 'process-exit', { + outcome, + }), + stopReason: 'error', + }) + await run.dispose().catch(() => {}) + } + { + const child = fakeChild({ exitOnTerminate: false }) + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + codexErrorInfo: 'other', + })) + await nextTask() + child.fromChild.emit('end') + child.settle({ exitCode: 17, signal: 'SIGABRT' }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: expectedFailureDiagnostic('turn', 'other'), + stopReason: 'error', + }) + await run.dispose().catch(() => {}) + } { const child = fakeChild() const { run, turnStart } = await publishRun(child, undefined, { + disposeGraceMs: 10, onError: () => { throw new Error('diagnostic sink') }, }) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -1700,6 +1692,19 @@ describe('run lifecycle and quiescence', () => { .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown')) await expect(spawnFailure).rejects.not.toThrow('SECRET_TOKEN') + const asyncSpawnFailureChild = fakeChild({ + pid: -1, + doneError: new Error('SECRET_TOKEN async spawn failure'), + }) + const asyncSpawnFailure = startCodexRun( + request(), + runSpec(asyncSpawnFailureChild), + ) + await expect(asyncSpawnFailure) + .rejects.toThrow(expectedFailureDiagnostic('initialize', 'unknown')) + await expect(asyncSpawnFailure).rejects.not.toThrow('SECRET_TOKEN') + expect(asyncSpawnFailureChild.terminate).not.toHaveBeenCalled() + const child = fakeChild() const starting = startCodexRun(request(), runSpec(child)) const initialize = await child.peer.nextMethod('initialize') @@ -1709,6 +1714,31 @@ describe('run lifecycle and quiescence', () => { await expect(starting).rejects.not.toThrow('invalid initialize response') expect(child.terminate).toHaveBeenCalledTimes(1) + const cleanupFailureChild = fakeChild({ + waitForExitError: new Error('SECRET_TOKEN wait failure'), + }) + const cleanupFailure = startCodexRun( + request(), + runSpec(cleanupFailureChild), + ) + const cleanupFailureInitialize = await cleanupFailureChild.peer + .nextMethod('initialize') + cleanupFailureChild.peer.respond(cleanupFailureInitialize, null) + const cleanupError: unknown = await cleanupFailure.then( + () => undefined, + (error: unknown) => error, + ) + expect(cleanupError).toBeInstanceOf(AggregateError) + expect(String(cleanupError)).toContain( + expectedFailureDiagnostic('initialize', 'unknown'), + ) + expect(String(cleanupError)).toContain(expectedFailureDiagnostic( + 'teardown', + 'unknown', + { outcome: { exitCode: 0, signal: null } }, + )) + expect(String(cleanupError)).not.toContain('SECRET_TOKEN') + const cleanupRaceAbort = new AbortController() const cleanupRaceChild = fakeChild({ exitOnTerminate: false }) const cleanupRace = startCodexRun( @@ -1796,27 +1826,6 @@ describe('run lifecycle and quiescence', () => { expect(child.terminate).toHaveBeenCalledTimes(1) }) - it('rolls back a subprocess done rejection during startup', async () => { - const child = fakeChild({ doneError: new Error('spawn observer failed') }) - const error: unknown = await startCodexRun(request(), runSpec(child)).then( - () => undefined, - (failure: unknown) => failure, - ) - expect(error).toBeInstanceOf(AggregateError) - if (!(error instanceof AggregateError)) { - throw new Error('expected startup and rollback failures') - } - expect(error.errors).toEqual([ - expect.objectContaining({ - message: `subagent-codex: ${expectedFailureDiagnostic('initialize', 'unknown')}`, - }), - expect.objectContaining({ - message: `subagent-codex: ${expectedFailureDiagnostic('teardown', 'unknown')}`, - }), - ]) - expect(child.terminate).toHaveBeenCalledTimes(1) - }) - it('keeps overlapping runs isolated', async () => { const initialStderrListeners = { error: process.stderr.listenerCount('error'), @@ -1957,7 +1966,6 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ - argv: codexAppServerArgv(), env: { OPENAI_API_KEY: 'fake' }, graceMs: 25, cwd: process.cwd(), @@ -2020,39 +2028,24 @@ describe('disposeCodexChild', () => { expect(child.waitForExit).not.toHaveBeenCalled() }) - it('reports direct-child observer failure and accepts absent stdin', async () => { - { - const child = fakeChild({ - doneError: new Error('close observer failed'), - }) - const wire = defaultWire(child) - await expect(disposeCodexChild(wire, child.handle)) - .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) - } - { - const child = fakeChild() - const handle = { ...child.handle, stdin: undefined } - const wire = defaultWire(child) - await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() - } + it('accepts absent stdin', async () => { + const child = fakeChild() + const handle = { ...child.handle, stdin: undefined } + const wire = defaultWire(child) + await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() }) - it('aggregates wire-close and tree-wait failures with safe teardown facts', async () => { + it('reports tree-wait failure with safe teardown facts', async () => { const child = fakeChild({ waitForExitError: new Error('SECRET_TOKEN wait failure'), }) const wire = defaultWire(child) - vi.spyOn(wire, 'close').mockImplementation(() => { - throw new Error('/private/secret.txt close failure') - }) const disposal = disposeCodexChild(wire, child.handle) - await expect(disposal).rejects.toBeInstanceOf(AggregateError) await expect(disposal).rejects.toThrow(expectedFailureDiagnostic( 'teardown', 'unknown', { outcome: { exitCode: 0, signal: null } }, )) await expect(disposal).rejects.not.toThrow('SECRET_TOKEN') - await expect(disposal).rejects.not.toThrow('/private/secret.txt') }) }) From 2442e63360da189f9647e81058a98bd8afec5fff Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:14:20 +0800 Subject: [PATCH 085/137] fix: cr --- ...08-18-tool-row-file-open-failure.i18n.yaml | 4 ++-- .../2026-08-18-tool-row-file-open-failure.md | 8 +++---- ...026-08-18-tool-row-file-open-failure.zh.md | 8 +++---- apps/web/tests/seeded-history.e2e.ts | 5 ++++- .../file-open-failure.expected.md | 7 +++++++ .../src/client/chat/ChatView.tsx | 21 +++++++++++++++---- .../src/client/contract/slots.ts | 7 ++++--- .../ui-conversation/src/client/locales.ts | 4 ++++ .../tests/apply-inject.client.spec.tsx | 2 +- .../tests/chat-view.client.spec.tsx | 14 ++++++++++++- 10 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml index 0e7aafe5bc..711034b02c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.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-18-tool-row-file-open-failure.md -2026-08-18-tool-row-file-open-failure.md: 8b54fe323f1ea9dba302a9cb8cbf10944ac7ea3f -2026-08-18-tool-row-file-open-failure.zh.md: a969905729b6cccb510ca85bcc698ddbc33ccb54 +2026-08-18-tool-row-file-open-failure.md: e36552395b992e688fad35b3163b92c9f6189e43 +2026-08-18-tool-row-file-open-failure.zh.md: 72b6026d9eb44c74f13c987996e652008f35a78e diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md index 8b54fe323f..e36552395b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md @@ -12,9 +12,9 @@ The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.m ## Decision -The inject returns the `workspaces.openPath` promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or `fileOpen.unknown` when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog. +The inject returns the `workspaces.openPath` promise. The chat view wraps that opener: a rejection opens an in-page Modal with the thrown text (or the unknown-open fallback when that text is empty) and a Retry that repeats the same path; Cancel, Escape, the close control, and a mask click dismiss it. A later settlement after dismiss is ignored, so a cancelled in-flight refusal cannot reopen the dialog. -The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. +The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. The produced-files folder action opens `.`, and that refusal uses the folder title and unknown-open copy. The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path open failed: ` onto the wire error; the dialog does not unwrap that prefix. @@ -22,7 +22,7 @@ The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path - **Per-row inline error.** The Host call is conversation-owned and several entries share one opener; a row-local banner would duplicate the same refusal next to every click target. - **Toast without retry.** The product ask is the reason *and* a retry entry. The workspace folder-adoption dialog already pairs those two. -- **Chat-store persistence.** A failed open is transient view state. The chat store survives reload, so a leftover dialog would return after a refresh that cannot usefully retry the original gesture. +- **Chat-store remount persistence.** A failed open is transient view state. The chat store survives view remounts, so a leftover dialog would return after a tab switch that cannot usefully retry the original gesture. ## Consequences @@ -30,4 +30,4 @@ A silent Host refusal is no longer a success from the reader's seat. Headless or ## Testing -Package specs cover inject rejection, the dialog copy (Error, non-Error, empty), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row and asserts the English reason plus a second call with the same payload. +Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row, pins the assembled dialog in `file-open-failure.expected.md`, and asserts the English reason plus a second call with the same payload. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md index a969905729..72b6026d9e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md @@ -12,9 +12,9 @@ Status: implemented ## 决策 -inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用 `fileOpen.unknown`),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。 +inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器:拒绝时打开页面内 Modal,展示抛出的文本(文本为空时用未知打开回退文案),并提供对同一路径的重试;取消、Escape、关闭控件和点击遮罩会关掉对话框。关闭之后才落到的结果会被忽略,因此已取消的进行中拒绝不能再次打开对话框。 -对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。 +对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。产物文件的文件夹操作打开 `.`,该拒绝使用文件夹标题和未知打开回退文案。 Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。 @@ -22,7 +22,7 @@ Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错 - **按行内联错误。** Host 调用由会话拥有,多个入口共用一个打开器;行内横幅会在每个点击目标旁重复同一拒绝。 - **没有重试的 toast。** 产品要求同时给出原因和重试入口。工作区文件夹采纳对话框已经把这两者配对。 -- **写入 chat store 并持久化。** 打开失败是瞬时视图状态。chat store 会在刷新后存活,于是残留对话框会在无法有效重试原手势的刷新之后回来。 +- **写入 chat store 并跨 remount 保留。** 打开失败是瞬时视图状态。chat store 会在视图 remount 后存活,于是残留对话框会在无法有效重试原手势的页签切换之后回来。 ## 后果 @@ -30,4 +30,4 @@ Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错 ## 测试 -包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,并断言英文原因以及对同一 payload 的第二次调用。 +包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。 diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index ec12ddfeaf..30ad67f9ff 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -33,6 +33,7 @@ const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expecte // Command-row goldens over the same conversation after direct host commands. const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/command-row.expected.md', import.meta.url)) const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/feedback-row.expected.md', import.meta.url)) +const FILE_OPEN_FAILURE_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/file-open-failure.expected.md', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'seeded-history-web-e2e' @@ -427,6 +428,8 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.click() const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) await dialog.waitFor({ timeout: 5_000 }) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(FILE_OPEN_FAILURE_EXPECTED, snapshot, MODE) await expect.poll(() => dialog.innerText(), { timeout: 5_000 }) .toContain('path open failed: xdg-open is not available') await page.getByRole('button', { name: 'Retry' }).click() @@ -544,6 +547,6 @@ describe('web e2e: seeded history renders through cold resume', () => { // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'seed.jsonl', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', 'seed.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md new file mode 100644 index 0000000000..bd326673d9 --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md @@ -0,0 +1,7 @@ +- dialog "Couldn’t open file": + - heading "Couldn’t open file" [level=2] + - button "Close": + - img + - paragraph: path open failed: xdg-open is not available + - button "Cancel" + - button "Retry" diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index f2d2b98a43..80f9bc2d73 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -102,6 +102,11 @@ function openFailureMessage(error: unknown, fallback: string): string { return message === '' ? fallback : message } +/** ProducedFiles opens the session workspace as `.`. */ +function isFolderOpenPath(path: string): boolean { + return path === '.' +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -175,7 +180,7 @@ export function ChatView({ const requestOpenFile = useCallback((path: string) => { const id = ++fileOpenRequest.current setFileOpenBusy(true) - void Promise.resolve(openFile(path)).then( + void openFile(path).then( () => { if (id !== fileOpenRequest.current) return setFileOpenError(null) @@ -183,7 +188,13 @@ export function ChatView({ }, (error: unknown) => { if (id !== fileOpenRequest.current) return - setFileOpenError({ path, message: openFailureMessage(error, t('fileOpen.unknown')) }) + setFileOpenError({ + path, + message: openFailureMessage( + error, + t(isFolderOpenPath(path) ? 'fileOpen.folderUnknown' : 'fileOpen.unknown'), + ), + }) setFileOpenBusy(false) }, ) @@ -468,6 +479,7 @@ export function ChatView({ {fileOpenError !== null && ( void @@ -494,7 +507,7 @@ function FileOpenErrorDialog({ open onClose={onClose} closeLabel={t('close')} - title={t('fileOpen.title')} + title={t(isFolderOpenPath(path) ? 'fileOpen.folderTitle' : 'fileOpen.title')} description={message} footer={( <> diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index bdb34d51c0..6eb3442925 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -714,10 +714,11 @@ export interface ChatViewInjected { openDetails: (target: SelectionTarget) => void /** * Open a tool-arg filesystem path with the host OS default application - * (relative paths resolve against the session cwd). Rejects when the Host - * cannot hand the path off; the chat view shows that reason and a retry. + * (relative paths resolve against the session cwd). Always returns a + * promise: fulfills when the Host opens the path, rejects when it cannot + * hand the path off (the chat view shows that reason and a retry). */ - openFile: (path: string) => void | Promise + openFile: (path: string) => Promise loadOlder: () => void /** Resolve a session-authorized historical image for inline display. */ loadImage: (attachment: ImageAttachmentRef) => Promise diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index de851021b9..f1bc673458 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -94,6 +94,8 @@ export const zh = { 'chat.toBottom': '回到底部', 'fileOpen.title': '无法打开文件', 'fileOpen.unknown': '无法打开此文件', + 'fileOpen.folderTitle': '无法打开文件夹', + 'fileOpen.folderUnknown': '无法打开此文件夹', 'message.extraBlock': '附加内容块', 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', @@ -266,6 +268,8 @@ export const en = { 'chat.toBottom': 'Back to bottom', 'fileOpen.title': 'Couldn’t open file', 'fileOpen.unknown': 'Couldn’t open this file', + 'fileOpen.folderTitle': 'Couldn’t open folder', + 'fileOpen.folderUnknown': 'Couldn’t open this folder', 'message.extraBlock': 'Extra content block', 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 38801e486a..cf07edaf31 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -244,7 +244,7 @@ describe('conversation slot inject API', () => { const b = await bench() b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available'))) const { injected } = b.chatViewApi(ROOT) - await expect(Promise.resolve(injected.openFile('src/a.ts'))).rejects.toThrow('xdg-open is not available') + await expect(injected.openFile('src/a.ts')).rejects.toThrow('xdg-open is not available') await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index 04539b5d05..1cd7f01a14 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -152,7 +152,7 @@ function emptyWorkspaces() { function makeHarness(init?: Partial) { const { set, source } = makeSource(init) const openDetails = vi.fn<(t: SelectionTarget) => void>() - const openFile = vi.fn<(path: string) => void>() + const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) const loadOlder = vi.fn() const inspectCall = vi.fn<(callId: string) => void>() // In-memory scroll memory matching the apply.ts per-session map contract. @@ -1021,6 +1021,18 @@ describe('ChatView', () => { }) }) + it('names a workspace-folder Host refusal as a folder', async () => { + const openFile = vi.fn<(path: string) => Promise>() + .mockRejectedValueOnce(new Error('')) + const h = makeHarness({ nodes: [toolResult(3, 'a')] }) + h.props.openFile = openFile + render() + await act(async () => { h.toolOwners[0]!.openFile('.') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件夹' }).textContent).toContain('无法打开此文件夹') + }) + }) + it('ignores a Host refusal that settles after the dialog is dismissed', async () => { let rejectRetry!: (error: unknown) => void const openFile = vi.fn<(path: string) => Promise>() From 20a5f5a3eec211f1a4ab5f6fdcc0e3862d26a344 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:25:13 +0800 Subject: [PATCH 086/137] feat(ui-tool): integrate connection handling for POSIX home path abbreviation --- .../2026-08-18-web-home-path-tilde.i18n.yaml | 6 +++ .../feature/2026-08-18-web-home-path-tilde.md | 37 +++++++++++++++ .../2026-08-18-web-home-path-tilde.zh.md | 37 +++++++++++++++ apps/web/tests/assembled-boot.ts | 4 +- apps/web/tests/home-path-tilde.snapshot.ts | 45 +++++++++++++++++++ .../workspace-hover.expected.txt | 2 + .../client/connection/src/client/fixture.ts | 11 ++++- .../client/connection/src/client/index.ts | 2 +- .../tests/connection.client.spec.ts | 10 ++--- .../connection/tests/fake-api.client.ts | 3 +- .../connection/tests/fixture.client.spec.ts | 18 +++++--- 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/workspaces/path.ts | 25 ++++++++++- .../runtime/tests/client-apply.client.spec.ts | 4 +- .../client/runtime/tests/fake-api.client.ts | 3 +- .../client/runtime/tests/path.client.spec.ts | 41 +++++++++++++++++ .../runtime/tests/wire-events.client.spec.ts | 2 +- .../tests/produced-files.client.spec.tsx | 2 +- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- packages/client/ui-tool/package.json | 2 + packages/client/ui-tool/src/client/apply.ts | 15 ++++++- .../ui-tool/src/client/contract/slots.ts | 18 +++++++- packages/client/ui-tool/src/client/index.ts | 4 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 15 +++++-- .../ui-tool/src/client/tool/ToolDetails.tsx | 16 +++---- .../src/client/tool/models/read-card-model.ts | 10 +++-- .../src/client/tool/models/tool-call-model.ts | 8 +++- .../client/tool/toolviews/GenericToolCard.tsx | 6 +-- .../tool/toolviews/file-mutation-row.tsx | 4 +- .../src/client/tool/toolviews/read-row.tsx | 6 +-- .../ui-tool/tests/read-card.client.spec.tsx | 9 ++++ .../tests/tool-call-tree.client.spec.tsx | 1 + .../tests/tool-details-render.client.tsx | 8 +++- .../ui-tool/tests/tool-row.client.spec.tsx | 26 +++++++++++ packages/client/ui-tool/tsconfig.json | 3 ++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- packages/client/ui-workspace/package.json | 3 ++ .../src/client/WorkspaceBrowser.tsx | 8 +++- .../ui-workspace/src/client/contract/slots.ts | 9 ++++ .../client/ui-workspace/src/client/index.ts | 12 ++++- .../ui-workspace/src/client/rows/Rows.tsx | 15 +++++-- .../ui-workspace/tests/apply.client.spec.ts | 6 ++- .../tests/rename-assembly.client.spec.tsx | 3 ++ .../ui-workspace/tests/rows.client.spec.tsx | 38 ++++++++++++++++ .../tests/workspace-browser.client.spec.tsx | 1 + packages/client/ui-workspace/tsconfig.json | 3 ++ packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 2 + packages/host/apiproxy/src/api/host.schema.ts | 1 + packages/host/apiproxy/src/api/host.ts | 2 + .../tests/api-proxy-workspace.spec.ts | 3 +- .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 2 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 7 ++- pnpm-lock.yaml | 3 ++ 64 files changed, 475 insertions(+), 82 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md create mode 100644 .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md create mode 100644 apps/web/tests/home-path-tilde.snapshot.ts create mode 100644 apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt create mode 100644 packages/client/runtime/tests/path.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml new file mode 100644 index 0000000000..e6983cb11f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.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-18-web-home-path-tilde.md +2026-08-18-web-home-path-tilde.md: 4b9b24454bbeeb394480c0c30470b7383a257790 +2026-08-18-web-home-path-tilde.zh.md: d901caab361755de44f6384d1016faf125175822 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md new file mode 100644 index 0000000000..4b9b24454b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md @@ -0,0 +1,37 @@ +# Agent Note: Web UI abbreviates POSIX home paths as `~` + +Status: implemented + +English | [中文](2026-08-18-web-home-path-tilde.zh.md) + +## Problem + +Workspace hover cards and Tool call summaries showed full POSIX home paths. Those strings are long, repeat the same prefix on every row, and make the sidebar and transcript harder to scan. Windows paths must stay verbatim because `~` is not a Windows filesystem convention. + +## Decision + +`host.describe` reports the host account `home` as a required field. Client and Host ship together, so the field is required rather than optional. ApiProxy fills it from `homedir()` at describe time. + +`abbreviateHomePath` in `dsh-client-runtime` is the display-only helper. It returns `~` or `~/…` when the path is the POSIX home or a descendant, and leaves the path unchanged when `home` is missing, empty, or `/`, when either value is a Windows drive or UNC path, or when the match is only a prefix (`/Users/u` does not claim `/Users/u2`). Tool summaries run workspace-relative shortening first, then this helper, so a path inside the session cwd stays short. `filePath`, Host open, and Workspace hover copy keep the authored filesystem path. + +`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. A missing `hostDescription` on an incomplete test fake falls back to an absent source, so abbreviation does not run. + +The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/fixture/Documents/project` lets assembled replay hover `~/Documents/project` without moving the existing `/tmp/fixture` account. TerminalBlock's own prompt-label collapse is unchanged. + +## Alternatives considered + +**Guess `/Users` or `/home` without the real home.** Rejected because a shared prefix is not an account home, and `/Users/shared` or `/home/src` would abbreviate incorrectly. + +**Abbreviate Windows `%USERPROFILE%` as `~` as well.** Rejected because the acceptance rule keeps Windows paths verbatim, and `~` is not how Explorer or `cmd` spell those paths. + +**Put the helper in `dsh-home-paths`.** Rejected because that package expands configuration tildes on Node; this helper is a browser display rewrite and must not pull Node `os` into client bundles. + +**Thread `home` from ChatView owner props.** Rejected because it enlarges the conversation inject face and every ChatView test harness for a display fact only Tool and Workspace cards consume. + +## Consequences + +POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Incomplete test connection fakes without `hostDescription` render unabbreviated paths instead of hanging or throwing. + +## Testing + +Package tests cover `abbreviateHomePath`, `toolRowModel` / `readCardModel` home abbreviation, Workspace hover display versus copy, and `host.describe` schema plus live `homedir()`. Assembled replay `apps/web/tests/home-path-tilde.snapshot.ts` hovers the fixture home-descendant Workspace. Product-GUI PRs still record a real-browser GIF of the hover card. diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md new file mode 100644 index 0000000000..d901caab36 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web UI abbreviates POSIX home paths as `~` + +Status: implemented + +[English](2026-08-18-web-home-path-tilde.md) | 中文 + +## Problem + +Workspace 悬停卡片和 Tool 调用摘要会显示完整的 POSIX 家目录路径。这些字符串很长,每行重复同一前缀,侧边栏和对话记录更难扫读。Windows 路径必须保持原样,因为 `~` 不是 Windows 文件系统约定。 + +## Decision + +`host.describe` 把宿主账户的 `home` 作为必填字段上报。Client 与 Host 一同发布,因此该字段是必填而不是可选。ApiProxy 在 describe 时用 `homedir()` 填入。 + +`dsh-client-runtime` 中的 `abbreviateHomePath` 是仅用于展示的辅助函数。当路径是 POSIX 家目录或其后代时返回 `~` 或 `~/…`;`home` 缺失、为空或为 `/`,任一侧是 Windows 盘符或 UNC 路径,或只是前缀命中(`/Users/u` 不能收走 `/Users/u2`)时,路径保持不变。Tool 摘要先做工作区相对缩短,再调用该辅助函数,因此会话 cwd 内的路径仍然更短。`filePath`、Host 打开以及 Workspace 悬停复制仍使用作者给出的文件系统路径。 + +`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。测试假对象若缺少 `hostDescription`,会回退到空来源,因此不会进行缩写。 + +fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位于 `/home/fixture/Documents/project`,组装回放可以悬停出 `~/Documents/project`,而不必移动现有的 `/tmp/fixture` 账户。TerminalBlock 自有的提示符标签折叠保持不变。 + +## Alternatives considered + +**在没有真实 home 的情况下猜测 `/Users` 或 `/home`。** 否决,因为共享前缀不是账户家目录,`/Users/shared` 或 `/home/src` 会被错误缩写。 + +**同样把 Windows `%USERPROFILE%` 缩写成 `~`。** 否决,因为验收规则要求 Windows 路径保持原样,而且 Explorer 与 `cmd` 并不这样拼写这些路径。 + +**把辅助函数放进 `dsh-home-paths`。** 否决,因为该包在 Node 上展开配置里的波浪号;本辅助函数是浏览器展示改写,不能把 Node `os` 拉进 client 包。 + +**从 ChatView owner props 向下传递 `home`。** 否决,因为它会扩大 conversation 注入面和每一份 ChatView 测试夹具,而只有 Tool 与 Workspace 卡片消费这个展示事实。 + +## Consequences + +POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。缺少 `hostDescription` 的不完整测试连接假对象会渲染未缩写路径,而不是挂起或抛错。 + +## Testing + +包测试覆盖 `abbreviateHomePath`、`toolRowModel`/`readCardModel` 的家目录缩写、Workspace 悬停展示与复制,以及 `host.describe` schema 与实时 `homedir()`。组装回放 `apps/web/tests/home-path-tilde.snapshot.ts` 悬停 fixture 中位于家目录下的 Workspace。面向产品 GUI 的 PR 仍需录制悬停卡片的真实浏览器 GIF。 diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 52c0658e4e..4e1ed1206f 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -30,7 +30,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ { id: '@deepseek-ai/dsh-client-ui-layout', bundlePath: 'packages/client/ui-layout/lib/client.js', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] }, { id: '@deepseek-ai/dsh-client-ui-sidebar', bundlePath: 'packages/client/ui-sidebar/lib/client.js', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, { id: '@deepseek-ai/dsh-client-ui-conversation', bundlePath: 'packages/client/ui-conversation/lib/client.js', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] }, + { id: '@deepseek-ai/dsh-client-ui-tool', bundlePath: 'packages/client/ui-tool/lib/client.js', url: '/plugins/ui-tool.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-workflow-run', bundlePath: 'packages/client/ui-workflow-run/lib/client.js', url: '/plugins/ui-workflow-run.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-workspace', @@ -38,6 +38,8 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ url: '/plugins/ui-workspace.js', rev: 'fx', inject: [ + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-locale', '@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar', diff --git a/apps/web/tests/home-path-tilde.snapshot.ts b/apps/web/tests/home-path-tilde.snapshot.ts new file mode 100644 index 0000000000..059a0ac003 --- /dev/null +++ b/apps/web/tests/home-path-tilde.snapshot.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +// Assembled POSIX home-path display: the fixture Host home is `/home/fixture` +// and a second Workspace lives under it. The sidebar hover card must show +// `~/Documents/project` while copy still writes the full path. +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts' + +const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt') + +installAssembledBootEnv() + +describe('assembled POSIX home-path display', () => { + it('shows the home-descendant Workspace path as ~ and copies the full path', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const group = (await within(tree).findAllByText('project')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group == null) throw new Error('home-descendant Workspace group missing') + + fireEvent.pointerEnter(group.parentElement as HTMLElement) + const hoverPath = await waitFor(() => { + const found = screen.getByText('~/Documents/project') + expect(found).toBeTruthy() + return found + }, { timeout: 2_000 }) + expect(screen.queryByText('/home/fixture/Documents/project')).toBeNull() + const copy = screen.getByRole('button', { name: 'Copy: /home/fixture/Documents/project' }) + + const shape = [ + `hover=${hoverPath.textContent}`, + `copy=${copy.getAttribute('aria-label')}`, + ].join('\n') + '\n' + if (REFRESHING_GOLDEN) { + mkdirSync(dirname(EXPECTED), { recursive: true }) + writeFileSync(EXPECTED, shape) + } + await expect(shape).toMatchFileSnapshot(EXPECTED) + act(() => { fireEvent.pointerLeave(group.parentElement as HTMLElement) }) + }) +}) diff --git a/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt b/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt new file mode 100644 index 0000000000..23ed9fb622 --- /dev/null +++ b/apps/web/tests/snapshots/home-path-tilde/workspace-hover.expected.txt @@ -0,0 +1,2 @@ +hover=~/Documents/project +copy=Copy: /home/fixture/Documents/project diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index dd0566486e..b0fbcfac22 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1551,6 +1551,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // live under one workspace, whose account carries them in attach order. const wid = (raw: string): WorkspaceId => raw as WorkspaceId const fixtureEpoch = new Date(Date.now() - 300_000).toISOString() + const FIXTURE_HOME = '/home/fixture' const workspaces: WorkspaceView[] = options.empty ? [] : [{ workspaceId: wid('fx-ws-fixture'), path: '/tmp/fixture', @@ -1558,6 +1559,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { sessionIds: [sid('fx-alpha'), sid('fx-beta'), sid('fx-gamma')], createdAt: fixtureEpoch, updatedAt: fixtureEpoch, + }, { + workspaceId: wid('fx-ws-home'), + path: `${FIXTURE_HOME}/Documents/project`, + title: 'project', + sessionIds: [], + createdAt: fixtureEpoch, + updatedAt: fixtureEpoch, }] let nextWorkspace = 1 // Registry-global archive set mirroring the host: archived sessions keep @@ -1568,7 +1576,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // deterministic content mirroring the design mock so assembled Web tests // and snapshots can walk it. Leaves are materialized lazily: a child listed // by its parent lists as empty until something is created inside it. - const FIXTURE_HOME = '/home/fixture' const directoryTree = new Map([ ['/', ['home']], ['/home', ['fixture']], @@ -2523,7 +2530,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, host: { describe: request => ok(request, { - version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, canOpenPath: true, + version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, }), // Deterministic native pick: the keyless lanes drive the full // pick-then-adopt path without an OS chooser (design-mock content, diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index c4470b13aa..9847d48cdf 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -62,7 +62,7 @@ export interface ConnectionHandle { readonly api: IApiClient /** Whether the current page authority is loopback; non-browser contexts default to true. */ readonly isLoopback: boolean - /** Generation-scoped Host facts, including native path-open capability. */ + /** Generation-scoped Host facts, including the account home and native path-open capability. */ readonly hostDescription: HostDescriptionSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index e5290c0dbb..7965d627f4 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -80,7 +80,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff expect(connected).toBe(0) // never announced during the failed generation - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() @@ -102,7 +102,7 @@ describe('connection lifecycle', () => { }, }) } - return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) } let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) @@ -188,7 +188,7 @@ describe('connection lifecycle', () => { describeCalls++ return describeCalls === 1 ? firstDescribe.promise - : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) } const states: ConnectionState[] = [] let connected = 0 @@ -201,7 +201,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) }) api.endStreams() - firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(describeCalls).toBe(2) }) await vi.waitFor(() => { expect(connected).toBe(1) }) @@ -283,7 +283,7 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(3) }) - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission } finally { diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index bee4fc0ce0..7c9dc6accb 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -74,10 +74,11 @@ export class FakeApiClient implements IApiClient { version: string cwd: string attachedSessions: number + home: string canOpenPath: boolean }>> = () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true, + version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 11c4ddab8a..03a444739a 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -522,7 +522,9 @@ describe('createFixtureApi', () => { it('describe answers the fixture identity', async () => { const api = createFixtureApi() const response = await api.host.describe(req({})) - expect(response.result).toMatchObject({ ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1 } }) + expect(response.result).toMatchObject({ + ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1, home: '/home/fixture' }, + }) const empty = await createFixtureApi({ empty: true }).host.describe(req({})) expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) }) @@ -547,10 +549,16 @@ describe('createFixtureApi', () => { const api = createFixtureApi() const listed = await api.workspace.list(req({})) if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.items).toEqual([expect.objectContaining({ - workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', - sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], - })]) + expect(listed.result.value.items).toEqual([ + expect.objectContaining({ + workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', + sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], + }), + expect.objectContaining({ + workspaceId: 'fx-ws-home', path: '/home/fixture/Documents/project', title: 'project', + sessionIds: [], + }), + ]) // path collision → the existing entity comes back, created:false, no frame. const reused = await api.workspace.create(req({ path: '/tmp/fixture' })) if (!reused.result.ok) throw new Error('reuse failed') diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index cd46900b77..ee19d184a2 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: 1fb91bc8ca1bf9beae0ea12632acd9572db58670 -README.zh.md: 294a77d7081f5b475aca8b50f6c6d8322370375d +README.md: 51156bf60acebe0367ce7dedeb8d478b7f8fc148 +README.zh.md: 2c7838769367df1f4129064e3de2e64db5bb2eb4 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 1fb91bc8ca..51156bf60a 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -26,6 +26,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base SlotRegistry gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. +`abbreviateHomePath` is the display-only POSIX home abbreviation used by Web Workspace hover cards and Tool summaries; a Windows drive or UNC path stays verbatim, and a missing, empty, or filesystem-root home leaves the path unchanged. + `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.jobsBySession` mirrors the Host's `session/jobs` 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. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 294a77d708..2c78387693 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -26,6 +26,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线 SlotRegistry 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或条目 store。 +`abbreviateHomePath` 是 Web Workspace 悬停卡片与 Tool 摘要使用的仅展示 POSIX 家目录缩写;Windows 盘符或 UNC 路径保持原样,缺失、空或文件系统根的 home 不改写路径。 + `indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 `SessionListState.jobsBySession` 按 last-wins 镜像宿主的 `session/jobs` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b5433bb573..24a584d746 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -46,7 +46,7 @@ export type { SessionProvideChannelHost } from './sessions/provide.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' export { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from './workspaces/service.ts' -export { resolveWorkspacePath } from './workspaces/path.ts' +export { abbreviateHomePath, resolveWorkspacePath } from './workspaces/path.ts' // Contract only: the scope implementation and its Host transport belong to // dsh-client-ui-settings (see that package's settings-scope.ts). export type { diff --git a/packages/client/runtime/src/client/workspaces/path.ts b/packages/client/runtime/src/client/workspaces/path.ts index d1512177ad..8bb3aa6645 100644 --- a/packages/client/runtime/src/client/workspaces/path.ts +++ b/packages/client/runtime/src/client/workspaces/path.ts @@ -5,9 +5,32 @@ * @returns an absolute path when a workspace root is available, otherwise the original path. */ export function resolveWorkspacePath(cwd: string | undefined, path: string): string { - if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path + if (path.startsWith('/') || isWindowsStylePath(path)) return path if (cwd === undefined || cwd === '') return path const base = cwd.replace(/[/\\]+$/, '') const rel = path.replace(/^[/\\]+/, '') return `${base}/${rel}` } + +/** Drive-letter or UNC path; Web display must not rewrite these as `~`. */ +function isWindowsStylePath(value: string): boolean { + return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\') +} + +/** + * Display-only POSIX home abbreviation. Windows drive and UNC paths stay + * verbatim, including when `home` itself is a Windows path. A missing, empty, + * or filesystem-root `home` leaves `path` unchanged so `/` cannot become `~`. + * @param path - absolute or already-short display path. + * @param home - host account home from `host.describe`; absent skips abbreviation. + * @returns `~` or `~/…` for the POSIX home and its descendants, otherwise `path`. + */ +export function abbreviateHomePath(path: string, home?: string): string { + if (home === undefined || home === '') return path + if (isWindowsStylePath(path) || isWindowsStylePath(home)) return path + const root = home.replace(/\/+$/, '') + if (root === '' || root === '/') return path + if (path.replace(/\/+$/, '') === root) return '~' + if (path.startsWith(`${root}/`)) return `~${path.slice(root.length)}` + return path +} diff --git a/packages/client/runtime/tests/client-apply.client.spec.ts b/packages/client/runtime/tests/client-apply.client.spec.ts index 1026c55569..199181fe2e 100644 --- a/packages/client/runtime/tests/client-apply.client.spec.ts +++ b/packages/client/runtime/tests/client-apply.client.spec.ts @@ -91,7 +91,7 @@ describe('runtime client apply', () => { expect(workspaces.list.getSnapshot().items[0]?.workspaceId).toBe('w-new') // Mux sink and onConnected route without throwing (manager semantics own the behavior). bench.sinks?.onMuxEnvelope?.({ rpcId: 'r2' as never, payload: { type: 'stream/error', message: 'x' } as never }) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }) + bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) }) it('selects the recent Workspace once when the first baselines have no current session', async () => { @@ -104,7 +104,7 @@ describe('runtime client apply', () => { })) bench.api.onList = () => Promise.resolve(ok({ items: [] })) - bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true }) + bench.sinks?.onConnected?.({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true }) await flushMicrotasks() const sessions = bench.ctx.get('sessions') as SessionRuntime diff --git a/packages/client/runtime/tests/fake-api.client.ts b/packages/client/runtime/tests/fake-api.client.ts index 33a0efbbfd..e5bdaa5f66 100644 --- a/packages/client/runtime/tests/fake-api.client.ts +++ b/packages/client/runtime/tests/fake-api.client.ts @@ -108,10 +108,11 @@ export class FakeApiClient implements IApiClient { version: string cwd: string attachedSessions: number + home: string canOpenPath: boolean }>> = () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, canOpenPath: true, + version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) diff --git a/packages/client/runtime/tests/path.client.spec.ts b/packages/client/runtime/tests/path.client.spec.ts new file mode 100644 index 0000000000..455df0d112 --- /dev/null +++ b/packages/client/runtime/tests/path.client.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { abbreviateHomePath, resolveWorkspacePath } from '../src/client/workspaces/path.ts' + +describe('abbreviateHomePath', () => { + it('collapses a POSIX home and its descendants', () => { + expect(abbreviateHomePath('/Users/u', '/Users/u')).toBe('~') + expect(abbreviateHomePath('/Users/u/', '/Users/u')).toBe('~') + expect(abbreviateHomePath('/Users/u/Documents/project', '/Users/u')).toBe('~/Documents/project') + expect(abbreviateHomePath('/Users/u/Documents/project/', '/Users/u/')).toBe('~/Documents/project/') + }) + + it('keeps prefix-adjacent names and non-home paths', () => { + expect(abbreviateHomePath('/Users/u2/a.ts', '/Users/u')).toBe('/Users/u2/a.ts') + expect(abbreviateHomePath('/etc/hosts', '/Users/u')).toBe('/etc/hosts') + expect(abbreviateHomePath('src/a.ts', '/Users/u')).toBe('src/a.ts') + expect(abbreviateHomePath('~/already', '/Users/u')).toBe('~/already') + }) + + it('does not abbreviate when home is missing, empty, or the filesystem root', () => { + expect(abbreviateHomePath('/Users/u/a.ts')).toBe('/Users/u/a.ts') + expect(abbreviateHomePath('/Users/u/a.ts', '')).toBe('/Users/u/a.ts') + expect(abbreviateHomePath('/etc/hosts', '/')).toBe('/etc/hosts') + expect(abbreviateHomePath('/etc/hosts', '///')).toBe('/etc/hosts') + }) + + it('leaves Windows drive and UNC paths verbatim', () => { + expect(abbreviateHomePath('C:\\Users\\u\\project', 'C:\\Users\\u')).toBe('C:\\Users\\u\\project') + expect(abbreviateHomePath('C:/Users/u/project', '/Users/u')).toBe('C:/Users/u/project') + expect(abbreviateHomePath('/Users/u/project', 'C:\\Users\\u')).toBe('/Users/u/project') + expect(abbreviateHomePath('\\\\server\\share\\u', '\\\\server\\share\\u')).toBe('\\\\server\\share\\u') + }) +}) + +describe('resolveWorkspacePath', () => { + it('joins a relative path under cwd and passes absolute paths through', () => { + expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') + expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') + expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts') + expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') + }) +}) diff --git a/packages/client/runtime/tests/wire-events.client.spec.ts b/packages/client/runtime/tests/wire-events.client.spec.ts index f6798bd914..13365c1462 100644 --- a/packages/client/runtime/tests/wire-events.client.spec.ts +++ b/packages/client/runtime/tests/wire-events.client.spec.ts @@ -127,7 +127,7 @@ describe('wire event bridge', () => { const bench = await mount() let resets = 0 bench.ctx.on('connection/reset', () => { resets++ }) - const description = { version: '0', cwd: '/f', attachedSessions: 0, canOpenPath: true } + const description = { version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true } bench.sinks?.onConnected?.(description) bench.sinks?.onConnected?.(description) // second generation after a reconnect expect(resets).toBe(2) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 1521010497..b37e65c9ea 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -286,7 +286,7 @@ describe('ProducedFiles row', () => { ): Pick => { const description = canOpenPath === undefined ? undefined - : { version: 'test', cwd: '/workspace', attachedSessions: 1, canOpenPath } + : { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath } return { isLoopback, useHostDescription: selector => selector(description), diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index b41ee17acc..678a276a73 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 6e2bef2f5ad4b136510c3acbb8f8b8e83c4c9212 -README.zh.md: 169417747541db9e02cb552b175ca7c100420aeb +README.md: b87236309c9bafe3e35d3d5977d56bd62a24de31 +README.zh.md: 3bae0b3f4cb3ad695371ec7a66fb531a935a4d2f diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 6e2bef2f5a..b87236309c 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -28,7 +28,7 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd`, and plain `openFile`/`inspect` callbacks. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge. +The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. Path summaries relativize to the session cwd first, then replace a leftover POSIX host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge. This package currently owns the generic fallback and the built-in shell/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 1694177475..3bae0b3f4c 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -28,7 +28,7 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd`,以及普通的 `openFile`、`inspect` 回调。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。 +owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`、`inspect` 回调。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX 宿主家目录写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React node、运行时服务或 root/subcall 知识。 本包当前拥有 generic fallback,以及 shell/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 991e0d844e..01f1b3b1fb 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation" @@ -50,6 +51,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index ec2f0b8ec1..a226c5116b 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,4 +1,5 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ +import type { ConnectionHandle, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { ToolCallTree } from './tool/ToolCallTree.tsx' @@ -12,14 +13,22 @@ import { searchToolview } from './tool/toolviews/search-row.tsx' import { todoToolview } from './tool/toolviews/todo-row.tsx' import { webToolview } from './tool/toolviews/web-row.tsx' -/** Required service: the slot registry that owns both Tool render seats. */ -export const inject = ['slots'] +/** Required services: the slot registry and the Host description used for POSIX `~`. */ +export const inject = ['slots', 'connection'] + +const absentHostDescription: HostDescriptionSource = { + getSnapshot: () => undefined, + subscribe: () => () => {}, +} /** * Mount the whole-Tool renderers and built-in atomic Tool registrations. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { + const connection = ctx.get('connection') as ConnectionHandle + const hostDescription = connection.hostDescription ?? absentHostDescription + const toolInject = () => ({ hooks: { hostDescription } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', @@ -27,11 +36,13 @@ export function apply(ctx: ClientContext): void { children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' }, }, + inject: toolInject, }, ToolCallTree)) ctx.slots.inject('conversation.details.tool', () => ctx.slots.register({ name: 'conversation.details.tool', locale: NS, + inject: toolInject, }, ToolDetails)) ctx.plugin(bashToolviewSample) diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 04af306db4..a6cdd1fa2d 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,6 @@ /** Tool UI slot declarations and their composed component props. */ -import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -34,6 +35,8 @@ export interface ToolCallOwnerProps { block: ToolCallBlock /** Session workspace root for relative summaries. */ cwd?: string | undefined + /** Host account home; POSIX home-rooted summaries display as `~`. */ + home?: string | undefined /** Open a Tool argument path through the Host. */ openFile: (path: string) => void /** Inspect this call in the trajectory view when available. */ @@ -43,10 +46,21 @@ export interface ToolCallOwnerProps { /** Full props of a registered atomic Tool view. */ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> +/** Injected Host description for POSIX home-path display. */ +export type ToolHostDescriptionInjected = { + hooks: { + /** Current generation's Host description, bound by the slot renderer. */ + hostDescription: HostDescriptionSource + } +} + /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> + & InjectFace /** Full props of the selected Tool output renderer in the details panel. */ -export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> +export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> + & PropsLocale<'conversation'> + & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index 357506b1db..2079d09a96 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,3 +1,5 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' -export type { ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolTreeProps } from './contract/slots.ts' +export type { + ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostDescriptionInjected, ToolTreeProps, +} from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index db3ed0af06..3ed5fb9216 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -12,12 +12,13 @@ function callName(node: ToolCallBlock): string { /** One atomic call dispatched through the Tool-owned keyed slot. */ const ToolCall = memo(function ToolCall({ - renderSlot, callId, toolName, block, openFile, selected, cwd, inspectCall, t, children, + renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, t, children, }: Pick & { callId: string toolName: string block: ToolCallBlock selected: boolean + home?: string | undefined children?: ReactNode }) { const owner: ToolCallOwnerProps = useMemo(() => ({ @@ -26,8 +27,9 @@ const ToolCall = memo(function ToolCall({ block, openFile, cwd, + home, inspect: () => { inspectCall(callId) }, - }), [callId, toolName, block, openFile, cwd, inspectCall]) + }), [callId, toolName, block, openFile, cwd, home, inspectCall]) return (
& { block: ToolCallBlock + home?: string | undefined }) { return ( @@ -70,6 +74,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ block={child} selectedCallId={selectedCallId} cwd={cwd} + home={home} openFile={openFile} inspectCall={inspectCall} t={t} @@ -88,8 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostDescription, t, }: ToolTreeProps) { + const home = useHostDescription(description => description?.home) const block = node.data.root return ( ) { + const home = useHostDescription(description => description?.home) const terminal = terminalCardModel(block, cwd) if (terminal !== null) { return ( @@ -34,7 +30,7 @@ export function ToolDetails({ block, cwd, t }: ToolDetailsContentProps) { ) } - const read = readCardModel(block, cwd) + const read = readCardModel(block, cwd, home) if (read !== null) return const diff = diffCardModel(block) if (diff !== null) return diff --git a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts index 62a591bb17..e79a6979b8 100644 --- a/packages/client/ui-tool/src/client/tool/models/read-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/read-card-model.ts @@ -13,6 +13,7 @@ * until the result arrives. * @module */ +import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts' @@ -52,14 +53,15 @@ export type ReadCardModel = Pick ({ number: line.number, text: line.text })) return { - label: result.title ?? relativizeToCwd(result.path, sessionCwd), + label: result.title ?? abbreviateHomePath(relativizeToCwd(result.path, sessionCwd), home), lines, totalLines: result.totalLines, lang: result.lang, diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index fa7cb27962..3193d054b2 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -9,6 +9,7 @@ // The block union's defining home is runtime (fold-product types); this // contract only forwards it (type-definition authority stays with the layer // that produces the values). +import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -206,16 +207,19 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null { * @param toolName - wire tool name (dispatch-supplied; survives windowless results). * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @param cwd - session workspace root; workspace-rooted path summaries display relative to it. + * @param home - host account home; a leftover POSIX home path displays as `~`. * @returns the row model. */ -export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel { +export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string, home?: string): ToolRowModel { const variant = classifyTool(toolName) const done = 'kind' in block const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const state: ToolRowState = !done ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' - const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd) + const base = argsRaw === '' + ? block.callId + : abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home) const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool // name rides the mutable summary slot unless the tool owns a specific title. diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index ae4d18eec8..99fb68b9af 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -33,10 +33,10 @@ export interface GenericToolCardProps extends ToolCallOwnerProps { t: ToolTreeProps['t'] } -export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) { - const model = toolRowModel(toolName, block, cwd) +export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, t }: GenericToolCardProps) { + const model = toolRowModel(toolName, block, cwd, home) const terminal = terminalCardModel(block, cwd) - const read = readCardModel(block, cwd) + const read = readCardModel(block, cwd, home) const diff = diffCardModel(block) const search = searchCardModel(block) const web = webCardModel(block) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index 9616dab7cd..ad99ed7999 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -29,8 +29,8 @@ type FileMutationRowProps = ToolCallViewProps & PropsLocale<'conversation'> * model-facing error text through its Output section and its first line in the * collapsed summary instead. */ -export function FileMutationRow({ toolName, block, cwd, openFile, inspect, t }: FileMutationRowProps) { - const model = toolRowModel(toolName, block, cwd) +export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, t }: FileMutationRowProps) { + const model = toolRowModel(toolName, block, cwd, home) const diff = diffCardModel(block) return ( * read card as the row's collapsed-by-default card body. The summary path is an * openable host link when the row names a single file. */ -export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowProps) { - const model = toolRowModel(toolName, block, cwd) - const read = readCardModel(block, cwd) +export function ReadRow({ toolName, block, cwd, home, openFile, inspect, t }: ReadRowProps) { + const model = toolRowModel(toolName, block, cwd, home) + const read = readCardModel(block, cwd, home) return ( { .toBe('/w/app/src/a.ts') }) + it('abbreviates a leftover POSIX home path label', () => { + expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) }), '/tmp/ws', '/Users/u')?.label) + .toBe('~/notes.md') + expect(readCardModel(settled({ resultView: resultRead({ path: '/Users/u/app/src/a.ts' }) }), '/Users/u/app', '/Users/u')?.label) + .toBe('src/a.ts') + expect(readCardModel(settled({ resultView: resultRead({ path: 'C:\\Users\\u\\a.ts' }) }), '/tmp/ws', '/Users/u')?.label) + .toBe('C:\\Users\\u\\a.ts') + }) + it('carries an omitted language through as undefined', () => { const noLang = resultRead() delete (noLang as { lang?: string }).lang diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 44f4231802..441052556d 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -44,6 +44,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), + useHostDescription: (selector => selector(undefined)) as ToolTreeProps['useHostDescription'], t, } as unknown as ToolTreeProps } diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index b2b2cd9ac7..5e70747b98 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -4,6 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts' +import type { ToolDetailsProps } from '../src/client/contract/slots.ts' import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' /** Framework session-area seat used by direct DetailsPanel tests. */ @@ -58,6 +59,11 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr // PropsRenderSlots keeps its key generic even for this one-key share; // recover the concrete owner selected by the adapter's fixed slot. const details = owner as unknown as DetailsToolOwnerProps - return + return selector(undefined)) as ToolDetailsProps['useHostDescription']} + t={t} + /> } } diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 28c8b5b236..bedea5d720 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -137,6 +137,32 @@ describe('tool-call-model', () => { expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md') }) + it('abbreviates leftover POSIX home paths after cwd relativization', () => { + const home = '/Users/u' + const cwd = '/tmp/ws' + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u"}' }), cwd, home).summary).toBe('~') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/notes.md"}' }), cwd, home).summary) + .toBe('~/notes.md') + // Workspace-relative wins: a home-and-cwd descendant stays short, not `~/…`. + expect(toolRowModel( + 'read', + running({ name: 'read', argsRaw: '{"path":"/Users/u/proj/src/a.ts"}' }), + '/Users/u/proj', + home, + ).summary).toBe('src/a.ts') + // Prefix boundary: `/Users/u2` is not under `/Users/u`. + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u2/a.ts"}' }), cwd, home).summary) + .toBe('/Users/u2/a.ts') + expect(toolRowModel( + 'read', + running({ name: 'read', argsRaw: '{"path":"C:\\\\Users\\\\u\\\\a.ts"}' }), + cwd, + home, + ).summary).toBe('C:\\Users\\u\\a.ts') + expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/a.ts"}' }), cwd).summary) + .toBe('/Users/u/a.ts') + }) + it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => { expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}') expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw') diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 2bd46fec7b..1982d6eee1 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 6d5eff023b..ff81e52445 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: 8878aa49dcccd60ddcde5f0a9563bbfbd969c9c0 -README.zh.md: c83f1c53d5471dd8d52b933163a573eb98a70c2e +README.md: fb26b0386f729863514862ccb819cb3f99e96dc0 +README.zh.md: a32c5e2ca343c21b6a156da25807c5a96162e03f diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 8878aa49dc..fb26b0386f 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -8,7 +8,7 @@ The browser renders grouped or flat Session rows from the global runtime hooks a Collapsed search is one header action beside the view and add actions. In the rail, add and search render as 36px controls on the shell's shared horizontal entry path. Activating search expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail shows a POSIX home or descendant as `~` / `~/…` and leaves a Windows path verbatim. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index c83f1c53d5..a32c5e2ca3 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -8,7 +8,7 @@ 折叠搜索是视图和添加操作旁的一枚区头按钮。在轨道中,添加和搜索会渲染为沿外壳共用横向进入路径移动的 36px 控件。激活搜索后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情把 POSIX 家目录及其后代显示为 `~`/`~/…`,Windows 路径保持原样。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。 Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index e76b9e138c..f130a4eea9 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", @@ -49,6 +50,7 @@ "clsx": "^2.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -58,6 +60,7 @@ "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index d0f6835472..61f6c81bab 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -218,6 +218,8 @@ type SessionTreeProps = Pick< 'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' > & { + /** Host account home for POSIX hover-path abbreviation. */ + home?: string | undefined workspaces: readonly WorkspaceView[] /** Explicit persisted zero-or-five-session state by Workspace group. */ groupExpansion: Readonly> @@ -251,7 +253,7 @@ function SessionTree({ onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, - sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t, + sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current @@ -450,6 +452,7 @@ function SessionTree({ > { if (group.expanded) { @@ -758,9 +761,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, + useHostDescription, renderSlot, t, }: WorkspaceBrowserProps) { + const home = useHostDescription(description => description?.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) @@ -1152,6 +1157,7 @@ export function WorkspaceBrowser({ insertWorkspaceBefore={insertWorkspaceBefore} insertSessionBefore={insertSessionBefore} orderBy={orderBy} + home={home} t={t} onRenameRequest={(workspaceId, currentTitle) => { setRenameTarget({ workspaceId, currentTitle }) diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 8027a3623a..3714a4fb98 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -22,6 +22,7 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ +import type { HostDescription, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. @@ -90,6 +91,10 @@ export type DirectoryPickingHooks = { * browsing region drives. */ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { + hooks: DirectoryPickingInjected['hooks'] & { + /** Current generation's Host description, bound by the slot renderer. */ + hostDescription: HostDescriptionSource + } /** * Start a New Session in a Workspace: reuse-or-create its blank session and * open it; without an explicit workspace, inherit the current Session @@ -144,6 +149,10 @@ export type WorkspaceBrowserProps = & PropsStore> & Omit & DirectoryPickingHooks + & { + /** Selector hook over the current generation's Host description. */ + useHostDescription: SnapshotSelectorHook + } & PropsLocale<'workspace'> /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 6b14243ecf..b149121fff 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -8,6 +8,7 @@ * client half (see the contract module doc). Export discipline: * packages/client/AGENTS.md. */ +import type { ConnectionHandle, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -42,7 +43,12 @@ const NS = 'workspace' * provides a waitable service. apply therefore depends on each slot * declaration through `slots.inject()` instead of assuming order. */ -export const inject = ['slots', 'sessions', 'workspaces', 'locale'] +export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'] + +const absentHostDescription: HostDescriptionSource = { + getSnapshot: () => undefined, + subscribe: () => () => {}, +} /** * Register the browser and picker once their slot declarations are on the @@ -51,6 +57,8 @@ export const inject = ['slots', 'sessions', 'workspaces', 'locale'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + const connection = ctx.get('connection') as ConnectionHandle + const hostDescription = connection.hostDescription ?? absentHostDescription ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => { @@ -99,7 +107,7 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), - hooks: { directoryFlow: browserFlowSource }, + hooks: { directoryFlow: browserFlowSource, hostDescription }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 481e0f0e47..d2d121fe4d 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -13,6 +13,7 @@ import { IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' +import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' import { relativeTime } from '../tree.ts' @@ -50,7 +51,7 @@ function createdLabel(createdAt: number, t: RowTranslate): string { return t('hover.created', { time: `${date} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` }) } -/** Hover-card body: workspace title, full directory path, absolute creation time. */ +/** Hover-card body: workspace title, display directory path, absolute creation time. */ function WorkspaceHoverContent({ label, cwd, createdAt, t }: { label: string cwd: string | undefined @@ -104,10 +105,11 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | * @param props.onToggle - expand/collapse the group. * @param props.onCreate - start a frontend Session inside this Workspace. * @param props.drag - optional workspace-row drag wiring. + * @param props.home - host account home for POSIX hover-path abbreviation. * @param props.t - the browser root's locale seat. * @returns the row element. */ -export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: { +export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, home, t }: { group: GroupNode onToggle: () => void onCreate: () => void @@ -115,6 +117,8 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: actions?: { rename: () => void; delete: () => void } | undefined /** Present only for real Workspace rows in the grouped view. */ drag?: WorkspaceRowDragProps | undefined + /** Host account home; POSIX home-rooted hover paths display as `~`. */ + home?: string | undefined t: RowTranslate }) { const row = group @@ -196,7 +200,12 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: return ( } + content={} disabled={menuOpen} copyText={row.cwd} copyLabel={t('copy')} diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 016af313f8..2f947d145d 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -36,6 +36,9 @@ async function bench() { create, startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never) + ctx.provide('connection', { + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) return { @@ -54,7 +57,7 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void { describe('ui-workspace apply', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale']) + expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale', 'connection']) }) it('registers browser and pickers for declarations arriving before or after apply', async () => { @@ -126,6 +129,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) + expect(browser.hooks.hostDescription.getSnapshot()).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index e61c997de1..5359678050 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -31,6 +31,9 @@ beforeEach(() => { localStorage.clear() }) /** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() + runtime.provide('connection', { + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.provide('locale', locale) runtime.slots.installLocale(locale) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index c7a153ff5f..96f36faefc 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -304,6 +304,44 @@ describe('workspace browser rows', () => { } }) + it('workspace hover card shows a POSIX home descendant as ~ and still copies the full path', async () => { + vi.useFakeTimers() + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: '/home/u/Documents/project', createdAt: 0, label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('~/Documents/project')).toBeTruthy() + expect(screen.queryByText('/home/u/Documents/project')).toBeNull() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /home/u/Documents/project' })) }) + expect(writeText).toHaveBeenCalledWith('/home/u/Documents/project') + } finally { + restoreClipboard() + vi.useRealTimers() + } + }) + + it('workspace hover card leaves a Windows path verbatim', async () => { + vi.useFakeTimers() + try { + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: 'C:\\Users\\u\\project', createdAt: 0, label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('C:\\Users\\u\\project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + it('ungrouped bucket renders no workspace menu', () => { const group: GroupNode = { key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped', diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index fbe42fc279..2a813f89f8 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -80,6 +80,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), + useHostDescription: selector => selector(undefined), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 32e8aa89ec..e419270fb8 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../ui-primitives" }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../runtime" }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5826f75f75..b658b72b5b 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: 607cd4e4176631b64daf4a298b5d86a75ccdce68 -README.zh.md: cdfb5aa65b3ad5b00596487aae6c99e2f9d4e433 +README.md: 27efe5a75eb6947d71f95c0a60e590c15a35887c +README.zh.md: 71f4e63a013170c6822a5c8dae28bc5f899b271a diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 607cd4e417..27efe5a75e 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -50,7 +50,7 @@ A stale continuation discards every partial result, deduplication entry, and cur Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method does not use the default 30-second unary timeout, while caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. -`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action. +`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, and `xdg-open` on desktop Linux). For `.html`, `.htm`, `.xhtml`, and `.svg`, macOS and desktop Linux prefer a named default browser and fall back to that application handoff when none can be named. WSL translates every Linux path through `wslpath -w` and hands the resulting Windows/UNC path to Windows `Invoke-Item`, including browser-renderable documents, instead of assuming a Linux desktop association. `host.describe.home` is the host account home directory. The Web client uses it to display POSIX home-rooted paths as `~`; Windows values are still reported and are not abbreviated. `host.describe.canOpenPath` advertises whether that handoff can reach a user-visible desktop: explicit gateway `nativeOpen` wins, an injected opener is usable by definition, and platform detection otherwise accepts macOS, Windows, WSL, or Linux with a display while rejecting headless/container Linux. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`; clients combine both facts before presenting a native action. The `agentPreset.list` domain exposes the deployment's preset roster so a browser can offer a choice when starting a session; each row carries its `trust` (a `user` preset is exactly as privileged as the plugins it names), whether it is the current default, and — when the preset cannot compose a session — a `broken` reason, because a damaged directory still occupies its id and a surface must be able to show and delete it rather than offer it and fail the session start. A deployment composing no presets answers with an empty roster rather than an error, because sharing the host composition is a valid deployment. `agentPreset.select` recomposes one session's agent from a different preset, and is allowed only while the session is blank: once a turn has run, that history was produced under the preset's tools and swapping them would strand logged tool calls, so the attempt answers `agent-preset-locked`. The agent and the session survive — only the composition is swapped, and a failed swap restores the previous one. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cdfb5aa65b..71f4e63a01 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -50,7 +50,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,不使用默认的 30 秒一元调用超时,而调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 -`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。 +`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,桌面 Linux 为 `xdg-open`)。对于 `.html`、`.htm`、`.xhtml` 与 `.svg`,macOS 和桌面 Linux 会优先使用能够确定的默认浏览器;无法确定时回退到上述应用交接。WSL 会通过 `wslpath -w` 转换每个 Linux 路径,并将所得 Windows/UNC 路径交给 Windows `Invoke-Item`,浏览器可渲染的文档也不例外,而非假定存在 Linux 桌面文件关联。`host.describe.home` 是宿主账户的家目录。Web 客户端用它把 POSIX 家目录路径显示为 `~`;Windows 值仍会上报,但不会缩写。`host.describe.canOpenPath` 会宣告这次交接能否抵达用户可见的桌面:网关显式配置的 `nativeOpen` 优先,注入的 opener 按定义可用,否则平台检测接受 macOS、Windows、WSL 或带 display 的 Linux,并拒绝 headless/容器 Linux。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制;客户端会组合这两个事实后再呈现原生操作。 `agentPreset.list` 领域向浏览器暴露部署的 preset 名单,使其在开启会话时能够提供选择;每一行携带它的 `trust`(`user` preset 的权限恰好等于它所引用的插件)、它是否为当前默认值,以及——当该 preset 无法组装会话时——一条 `broken` 原因:损坏的目录仍占着它的 id,界面必须能展示并删除它,而不是把它端出来然后在会话启动时失败。未组装任何 preset 的部署返回空名单而非错误,因为共用宿主组装本身就是一种有效部署。`agentPreset.select` 用另一个 preset 重组某个会话的 agent,且仅在会话空白时允许:一旦跑过任何轮次,那段历史就是在该 preset 的工具下产生的,替换会留下无法执行的已记录的工具调用,此时返回 `agent-preset-locked`。agent 与会话都不销毁——只替换组装,且替换失败会恢复原来的组装。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index c675b60907..b161d28d33 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' +import { homedir } from 'node:os' import { dirname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' @@ -2874,6 +2875,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro provider: selection.provider, model: selection.model, attachedSessions: ctx.agents.list().length, + home: homedir(), canOpenPath: canOpenPaths(), })) }, diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 7dd560d3a4..1a520f1e30 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,6 +17,7 @@ export const hostDescribeValueSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), + home: z.string(), canOpenPath: z.boolean(), }) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 33c55106f5..5e45fcf3a5 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -41,6 +41,7 @@ export interface HostApi { * applied when a new agent doesn't specify them explicitly, absent when the host configures * no explicit default (the adapter falls back internally); * attachedSessions = count of currently attached sessions (those with a live agent); + * home = the host account home directory (Web display abbreviation on POSIX); * canOpenPath = whether this deployment can hand a path to a user-visible native desktop. */ describe(request: RpcRequest<{}>): Promise> diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 54bdb015e4..efbb16d682 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -234,6 +234,7 @@ describe('host.openPath', () => { const headless = await harness(undefined, undefined, { canOpenPath: () => false }) expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) + expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) }) it('opens through the injected native boundary', async () => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 4130d8f210..de9d4ddab0 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -72,7 +72,7 @@ function scriptedApi(overrides: { }, host: { describe: r => ok(r, { - version: '0-test', cwd: '/t', attachedSessions: 0, canOpenPath: true, + version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, }), pickDirectory: r => ok(r, { path: null }), listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 2000f708ba..77432c55af 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -143,7 +143,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra rpcId: request.rpcId, result: { ok: true, - value: { version: 'v', cwd: '/w', attachedSessions: 0, canOpenPath: true }, + value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true }, }, } }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 66ae52f783..62d1a371da 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -312,15 +312,18 @@ describe('host domain schemas', () => { it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) const value = hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true, + version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, home: '/h', canOpenPath: true, }) expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true }) expect(hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: false, + version: '1', cwd: '/x', attachedSessions: 0, home: '/h', canOpenPath: false, }).provider).toBeUndefined() expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, })).toThrow() + expect(() => hostDescribeValueSchema.parse({ + version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true, + })).toThrow() }) it('validates the browse listing/creation payloads', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 436891f7c2..dcc95d35eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3032,6 +3032,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale From dd3ea3db2e37479086c378d96941cf7b2a966548 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:31:12 +0800 Subject: [PATCH 087/137] fix: ci --- apps/web/tests/seeded-history.e2e.ts | 4 ++++ .../client/ui-conversation/tests/apply-inject.client.spec.tsx | 2 +- .../client/ui-conversation/tests/chat-view.client.spec.tsx | 2 +- .../ui-conversation/tests/views-type-chain.client.spec.tsx | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 30ad67f9ff..a7220857ad 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -440,6 +440,10 @@ describe('web e2e: seeded history renders through cold resume', () => { timeout: 5_000, }).toBe(0) } finally { + // Shared page: a leftover mask blocks later cases even when this one fails. + if (await page.getByRole('dialog', { name: 'Couldn’t open file' }).count() > 0) { + await page.keyboard.press('Escape') + } openPath.mockRestore() } }) diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index cf07edaf31..fcd533c1bf 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -233,7 +233,7 @@ describe('conversation slot inject API', () => { it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => { const b = await bench() const { injected } = b.chatViewApi(ROOT) - injected.openFile('src/a.ts') + await injected.openFile('src/a.ts') await vi.waitFor(() => { expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) }) diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index 1cd7f01a14..b81dff8e6e 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -348,7 +348,7 @@ describe('Chat node rendering', () => { resolve: (value) => { if (value !== 'report.html') return undefined return { - open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, + open: () => { void h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, label: '打开 site/report.html', title: 'site/report.html', } diff --git a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx index 1e6d41c261..6f90658c5f 100644 --- a/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx +++ b/packages/client/ui-conversation/tests/views-type-chain.client.spec.tsx @@ -45,7 +45,7 @@ describe('view-ring type negatives (compile-time; body never runs)', () => { // @ts-expect-error openDetails takes a SelectionTarget, not a string props.openDetails('nope') // @ts-expect-error openFile takes a path string, not a SelectionTarget - props.openFile({ turnSeq: 1, callId: 'c' }) + void props.openFile({ turnSeq: 1, callId: 'c' }) return null } void chatProps From 6a5118eb5c474a9afb1331cb36e65efec79df9ca Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:44:03 +0800 Subject: [PATCH 088/137] fix: ci --- .../snapshots/seeded-history/file-open-failure.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md index bd326673d9..fece247a7a 100644 --- a/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md +++ b/apps/web/tests/snapshots/seeded-history/file-open-failure.expected.md @@ -2,6 +2,6 @@ - heading "Couldn’t open file" [level=2] - button "Close": - img - - paragraph: path open failed: xdg-open is not available + - paragraph: "path open failed: xdg-open is not available" - button "Cancel" - button "Retry" From b8670e3fbe17fcf55499aa76768f8fde93ee8ba0 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 19:58:55 +0800 Subject: [PATCH 089/137] fix: ci --- .../ui-workspace/tests/rows.client.spec.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 96f36faefc..f86e275c5e 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -326,6 +326,24 @@ describe('workspace browser rows', () => { } }) + it('workspace hover card without a directory omits the path and copy action', async () => { + vi.useFakeTimers() + try { + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: undefined, createdAt: 0, label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, sessions: [], + } + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getAllByText('Project')).toHaveLength(2) + expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy() + expect(screen.queryByRole('button', { name: /^复制:/ })).toBeNull() + } finally { + vi.useRealTimers() + } + }) + it('workspace hover card leaves a Windows path verbatim', async () => { vi.useFakeTimers() try { From b032097c2a651d0ca231f9c4f3ac212376615a54 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 20:02:37 +0800 Subject: [PATCH 090/137] fix(subagent): preserve terminal turn precedence --- packages/subagent/subagent-codex/src/run.ts | 17 +++++----------- .../tests/subagent-codex.spec.ts | 20 +++++++++++++++++-- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 61cfee871e..959aebaa7c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -331,10 +331,7 @@ export async function startCodexRun( const result: Promise = settleRunResult({ attempt: async () => { try { - const terminal = await Promise.race([ - wire.runTurn(texts, runAbort.signal), - processFailure, - ]) + const terminal = await wire.runTurn(texts, runAbort.signal) if (terminal.stopReason === 'completed') return terminal const facts = wire.collectFailure() return { ...terminal, diagnostic: recordFailureDiagnostic(facts) } @@ -357,15 +354,11 @@ export async function startCodexRun( // The wire failure remains authoritative when exit observation fails. } } - const facts = error instanceof CodexRunFailure - ? error.facts - : endedBeforeTerminal && processFailureFacts !== undefined - ? processFailureFacts - : wire.collectFailure() + const facts = endedBeforeTerminal && processFailureFacts !== undefined + ? processFailureFacts + : wire.collectFailure() recordFailureDiagnostic(facts) - throw error instanceof CodexRunFailure - ? error - : new CodexRunFailure(facts, thrown(error)) + throw new CodexRunFailure(facts, thrown(error)) } }, collectOutput, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 8c7acc81e1..7fa6ee6416 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1612,6 +1612,7 @@ describe('run lifecycle and quiescence', () => { onError: (error) => { errors.push(error.message) }, }) child.settle(outcome) + child.fromChild.emit('end') await expect(run.result).resolves.toEqual({ output: [], diagnostic: expectedFailureDiagnostic('process', 'process-exit', { @@ -1649,8 +1650,7 @@ describe('run lifecycle and quiescence', () => { child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { codexErrorInfo: 'other', })) - await nextTask() - child.fromChild.emit('end') + child.fromChild.end() child.settle({ exitCode: 17, signal: 'SIGABRT' }) await expect(run.result).resolves.toEqual({ output: [], @@ -1659,6 +1659,22 @@ describe('run lifecycle and quiescence', () => { }) await run.dispose().catch(() => {}) } + { + const child = fakeChild({ exitOnTerminate: false }) + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send( + agentMessage('answer', 'final_answer'), + turnCompleted('completed'), + ) + child.fromChild.end() + child.settle({ exitCode: 17, signal: 'SIGABRT' }) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + await run.dispose().catch(() => {}) + } { const child = fakeChild() const { run, turnStart } = await publishRun(child, undefined, { From 996f6e49a5ca880ce6ea57aa75b9b8c2d8640a2a Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 20:04:18 +0800 Subject: [PATCH 091/137] fix: ci --- packages/client/ui-tool/src/client/apply.ts | 10 ++-------- .../ui-tool/tests/assembly-surfaces.client.spec.tsx | 6 +++++- .../ui-tool/tests/chat-code-subcalls.client.spec.tsx | 6 +++++- .../ui-tool/tests/tool-details-render.client.tsx | 3 +-- .../ui-tool/tests/toolview-slot.client.spec.tsx | 12 ++++++++++-- packages/client/ui-workspace/src/client/index.ts | 9 ++------- .../tests/rename-assembly.client.spec.tsx | 2 +- 7 files changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index a226c5116b..a2cc912d0d 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,5 +1,5 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ -import type { ConnectionHandle, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { ToolCallTree } from './tool/ToolCallTree.tsx' @@ -16,19 +16,13 @@ import { webToolview } from './tool/toolviews/web-row.tsx' /** Required services: the slot registry and the Host description used for POSIX `~`. */ export const inject = ['slots', 'connection'] -const absentHostDescription: HostDescriptionSource = { - getSnapshot: () => undefined, - subscribe: () => () => {}, -} - /** * Mount the whole-Tool renderers and built-in atomic Tool registrations. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle - const hostDescription = connection.hostDescription ?? absentHostDescription - const toolInject = () => ({ hooks: { hostDescription } }) + const toolInject = () => ({ hooks: { hostDescription: connection.hostDescription } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 501d4c27b9..44a820a110 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -68,7 +68,11 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index cb4325a09e..d73c09013c 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -158,7 +158,11 @@ async function bench(snapshot: ConversationSnapshot) { } ctx.provide('workspaces', workspaces) ctx.provide('layout', layout) - ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) // ui-theme's Appearance row binds a durable scope through these two. ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index 5e70747b98..7d3ffefe7c 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -4,7 +4,6 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { DetailsSlotProps, DetailsToolOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/contract/slots.ts' -import type { ToolDetailsProps } from '../src/client/contract/slots.ts' import { ToolDetails } from '../src/client/tool/ToolDetails.tsx' /** Framework session-area seat used by direct DetailsPanel tests. */ @@ -62,7 +61,7 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr return selector(undefined)) as ToolDetailsProps['useHostDescription']} + useHostDescription={selector => selector(undefined)} t={t} /> } diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 09ec2f0d3f..bdfba88345 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -64,7 +64,11 @@ const LAYOUT_CHILDREN = { */ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) @@ -203,7 +207,11 @@ describe('keyed toolview hole through the real machinery', () => { describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() - runtime.provide('connection', { api: { settings: {} }, isLoopback: false }) + runtime.provide('connection', { + api: { settings: {} }, + isLoopback: false, + hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + }) // ui-theme's Appearance row binds a durable scope through these two. runtime.provide('remote', { $on: () => () => {} }) runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index b149121fff..93269c22c1 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -8,7 +8,7 @@ * client half (see the contract module doc). Export discipline: * packages/client/AGENTS.md. */ -import type { ConnectionHandle, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -45,11 +45,6 @@ const NS = 'workspace' */ export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection'] -const absentHostDescription: HostDescriptionSource = { - getSnapshot: () => undefined, - subscribe: () => () => {}, -} - /** * Register the browser and picker once their slot declarations are on the * ledger. Inject factories return plain callbacks; data reads use the @@ -58,7 +53,7 @@ const absentHostDescription: HostDescriptionSource = { */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle - const hostDescription = connection.hostDescription ?? absentHostDescription + const hostDescription = connection.hostDescription ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => { diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index 5359678050..c2e201469f 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -33,7 +33,7 @@ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() runtime.provide('connection', { hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, - } as never) + }) const locale = new LocaleRuntime(runtime.ctx) runtime.provide('locale', locale) runtime.slots.installLocale(locale) From 51c86af1e9c1434874705f30bdf29f2345964fac Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 20:09:55 +0800 Subject: [PATCH 092/137] chore: refresh client catalog and module graph for connection inject --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 6 ++++-- docs/module-graph.zh.md | 6 ++++-- .../cordis-client-runner/src/client/slot-catalog.ts | 8 ++++---- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 144729d0c9..1263940a9c 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: 54aa13217a01ed44b44925365526438d4c867928 -module-graph.zh.md: 33c2f53afeb94c6d844f8406c1043d780436f588 +module-graph.md: a7a9617121370b943782abeb03695e360460acc2 +module-graph.zh.md: 46136554a990f92e47f80d57a2a12ad866d755db diff --git a/docs/module-graph.md b/docs/module-graph.md index 54aa13217a..a7a9617121 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1329,6 +1329,7 @@ flowchart TD pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_client_connection pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1352,6 +1353,7 @@ flowchart TD pkg_client_ui_workflow_run --> pkg_session pkg_client_ui_workflow_run --> pkg_tool_workflow pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_ui_workspace --> pkg_client_connection pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_conversation @@ -1621,11 +1623,11 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `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-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 33c2f53afe..46136554a9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1331,6 +1331,7 @@ flowchart TD pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter pkg_client_ui_tool --> pkg_api_remotes + pkg_client_ui_tool --> pkg_client_connection pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1354,6 +1355,7 @@ flowchart TD pkg_client_ui_workflow_run --> pkg_session pkg_client_ui_workflow_run --> pkg_tool_workflow pkg_client_ui_workflow_run --> pkg_workflow + pkg_client_ui_workspace --> pkg_client_connection pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime pkg_client_ui_workspace --> pkg_client_ui_conversation @@ -1623,11 +1625,11 @@ flowchart TD | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | -| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `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-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4eba7c202b..6fb951da48 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -536,7 +536,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:56', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:57', }, { key: 'conversation.input.attachments', @@ -1678,7 +1678,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-workspace/src/client/contract/slots.ts:58', + source: 'packages/client/ui-workspace/src/client/contract/slots.ts:59', }, { key: 'tool.call.toolview', @@ -1695,7 +1695,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Open a Tool argument path through the Host. */\n openFile: (path: string) => void\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', + '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /** Open a Tool argument path through the Host. */\n openFile: (path: string) => void\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', ], ownerPropsReferences: [ 'Wire', @@ -1732,7 +1732,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:23', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:24', }, { key: 'tool.view.cordis', From f15ca233868aefe805fa900a953f3175caf7b27f Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 18 Aug 2026 20:14:40 +0800 Subject: [PATCH 093/137] fix: ci --- .../2026-08-18-web-home-path-tilde.i18n.yaml | 4 ++-- .../feature/2026-08-18-web-home-path-tilde.md | 4 ++-- .../2026-08-18-web-home-path-tilde.zh.md | 4 ++-- .../ui-tool/tests/read-card.client.spec.tsx | 18 ++++++++++++++-- .../tests/tool-call-tree.client.spec.tsx | 12 ++++++++++- .../tests/tool-details-render.client.tsx | 9 ++++++-- .../ui-tool/tests/tool-row.client.spec.tsx | 8 ------- .../ui-workspace/src/client/contract/slots.ts | 17 +++++---------- .../tests/workspace-browser.client.spec.tsx | 21 +++++++++++++++++++ 9 files changed, 66 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml index e6983cb11f..0c4a921c34 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.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-18-web-home-path-tilde.md -2026-08-18-web-home-path-tilde.md: 4b9b24454bbeeb394480c0c30470b7383a257790 -2026-08-18-web-home-path-tilde.zh.md: d901caab361755de44f6384d1016faf125175822 +2026-08-18-web-home-path-tilde.md: b148833bab09eadce4c9c1a362dd99d04eba5977 +2026-08-18-web-home-path-tilde.zh.md: 9d15cd6dca1128927389d5731dff6bf831cffe76 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md index 4b9b24454b..b148833bab 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md @@ -14,7 +14,7 @@ Workspace hover cards and Tool call summaries showed full POSIX home paths. Thos `abbreviateHomePath` in `dsh-client-runtime` is the display-only helper. It returns `~` or `~/…` when the path is the POSIX home or a descendant, and leaves the path unchanged when `home` is missing, empty, or `/`, when either value is a Windows drive or UNC path, or when the match is only a prefix (`/Users/u` does not claim `/Users/u2`). Tool summaries run workspace-relative shortening first, then this helper, so a path inside the session cwd stays short. `filePath`, Host open, and Workspace hover copy keep the authored filesystem path. -`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. A missing `hostDescription` on an incomplete test fake falls back to an absent source, so abbreviation does not run. +`ui-tool` and `ui-workspace` inject `connection.hostDescription` at their own slot registrations. ChatView does not grow a Host-description hook. The field is required on `ConnectionHandle`; test fakes supply a source whose snapshot may be undefined before connect. The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/fixture/Documents/project` lets assembled replay hover `~/Documents/project` without moving the existing `/tmp/fixture` account. TerminalBlock's own prompt-label collapse is unchanged. @@ -30,7 +30,7 @@ The fixture Host home is `/home/fixture`. A second fixture Workspace at `/home/f ## Consequences -POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Incomplete test connection fakes without `hostDescription` render unabbreviated paths instead of hanging or throwing. +POSIX home-rooted Workspace hover paths and leftover Tool path summaries display as `~`. Copy and open still use the full path. Windows drive and UNC paths never become `~`. A Host that reports `/` as home does not turn the whole filesystem into `~`. Before the first describe, or while reconnecting, the source snapshot is undefined and paths stay unabbreviated. ## Testing diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md index d901caab36..9d15cd6dca 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md @@ -14,7 +14,7 @@ Workspace 悬停卡片和 Tool 调用摘要会显示完整的 POSIX 家目录路 `dsh-client-runtime` 中的 `abbreviateHomePath` 是仅用于展示的辅助函数。当路径是 POSIX 家目录或其后代时返回 `~` 或 `~/…`;`home` 缺失、为空或为 `/`,任一侧是 Windows 盘符或 UNC 路径,或只是前缀命中(`/Users/u` 不能收走 `/Users/u2`)时,路径保持不变。Tool 摘要先做工作区相对缩短,再调用该辅助函数,因此会话 cwd 内的路径仍然更短。`filePath`、Host 打开以及 Workspace 悬停复制仍使用作者给出的文件系统路径。 -`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。测试假对象若缺少 `hostDescription`,会回退到空来源,因此不会进行缩写。 +`ui-tool` 与 `ui-workspace` 在各自的 slot 注册上注入 `connection.hostDescription`。ChatView 不增加 Host 描述钩子。该字段在 `ConnectionHandle` 上是必填的;测试假对象提供一个来源,其快照在连接完成前可以为 undefined。 fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位于 `/home/fixture/Documents/project`,组装回放可以悬停出 `~/Documents/project`,而不必移动现有的 `/tmp/fixture` 账户。TerminalBlock 自有的提示符标签折叠保持不变。 @@ -30,7 +30,7 @@ fixture 的 Host 家目录是 `/home/fixture`。第二个 fixture Workspace 位 ## Consequences -POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。缺少 `hostDescription` 的不完整测试连接假对象会渲染未缩写路径,而不是挂起或抛错。 +POSIX 家目录下的 Workspace 悬停路径,以及缩短 cwd 后仍落在家目录里的 Tool 路径摘要,会显示为 `~`。复制与打开仍使用完整路径。Windows 盘符和 UNC 路径永远不会变成 `~`。若 Host 把 `/` 报成 home,不会把整个文件系统收成 `~`。首次 describe 之前或重连期间,来源快照为 undefined,路径保持未缩写。 ## Testing diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index a6001d0d8f..8ae0b21cfc 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -261,7 +261,12 @@ describe('ReadRow keyed toolview', () => { }) describe('DetailsPanel Output section (read)', () => { - function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + function mount( + snapshot: ConversationSnapshot, + selection: SelectionTarget | null, + cwd?: string, + description?: Parameters[1], + ) { localStorage.clear() const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) @@ -282,7 +287,7 @@ describe('DetailsPanel Output section (read)', () => { return render( snapshot, subscribe: () => () => {} })} @@ -342,6 +347,15 @@ describe('DetailsPanel Output section (read)', () => { expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result') }) + it('abbreviates a leftover POSIX home path on the read card label', () => { + const view = mount(snapshot({ + nodes: [settled({ resultView: resultRead({ path: '/Users/u/notes.md' }) })], + }), target, '/tmp/ws', { + version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false, + }) + expect(view.getByText('~/notes.md')).toBeTruthy() + }) + it('a running read keeps the 运行中… placeholder (no result view)', () => { const view = mount(snapshot({ runningCalls: [running()] }), target) expect(view.getByText('运行中…')).toBeTruthy() diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 441052556d..7f18800303 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -2,6 +2,7 @@ /** ToolCallTree-owned root/subcall markers and selection projection. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' +import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -21,6 +22,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ( function props( block: ToolResultNode, selectedCallId?: string, + description?: HostDescription, ): ToolTreeProps { const snapshot = {} as ConversationSnapshot const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession'] @@ -44,7 +46,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - useHostDescription: (selector => selector(undefined)) as ToolTreeProps['useHostDescription'], + useHostDescription: (selector => selector(description)) as ToolTreeProps['useHostDescription'], t, } as unknown as ToolTreeProps } @@ -79,4 +81,12 @@ describe('ToolCallTree', () => { expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true') expect(nests).toHaveLength(2) }) + + it('abbreviates a POSIX home path in the generic tool summary', () => { + const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' }) + const view = render() + expect(view.getByText('~/docs/a.ts')).toBeTruthy() + }) }) diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index 7d3ffefe7c..c0332e9ac6 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,4 +1,5 @@ /** Test adapter for the production conversation.details.tool registration. */ +import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' import type { ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, SessionId, } from '@deepseek-ai/dsh-client-runtime/client' @@ -51,9 +52,13 @@ export function toolChatSnapshot( /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. + * @param description - optional Host description so the details card can abbreviate home paths. * @returns a direct-test renderSlot implementation. */ -export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotProps['renderSlot'] { +export function renderToolDetails( + t: TranslateNS<'conversation'>, + description?: HostDescription, +): DetailsSlotProps['renderSlot'] { return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; // recover the concrete owner selected by the adapter's fixed slot. @@ -61,7 +66,7 @@ export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotPr return selector(undefined)} + useHostDescription={selector => selector(description)} t={t} /> } diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index bedea5d720..f1a78401e2 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -5,7 +5,6 @@ import { cleanup, fireEvent, render } from '@testing-library/react' import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { resolveWorkspacePath } from '@deepseek-ai/dsh-client-runtime/client' import { classifyTool, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts' import { ToolRow } from '../src/client/tool/components/ToolRow.tsx' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' @@ -120,13 +119,6 @@ describe('tool-call-model', () => { expect(toolRowModel('bash', running()).filePath).toBeUndefined() }) - it('resolveWorkspacePath joins relative paths under cwd and passes absolute through', () => { - expect(resolveWorkspacePath('/w', 'src/a.ts')).toBe('/w/src/a.ts') - expect(resolveWorkspacePath('/w/', '/abs/a.ts')).toBe('/abs/a.ts') - expect(resolveWorkspacePath(undefined, 'src/a.ts')).toBe('src/a.ts') - expect(resolveWorkspacePath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts') - }) - it('displays workspace-rooted paths relative to the session cwd', () => { const cwd = '/Users/u/ws/' expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts') diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 3714a4fb98..bdd85052a0 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -22,8 +22,8 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ -import type { HostDescription, HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' -import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -80,17 +80,14 @@ export type DirectoryPickingInjected = { } /** Component-side view of the picking share: the bound occupancy selector hook. */ -export type DirectoryPickingHooks = { - /** Selector hook over this surface's directory-flow occupancy. */ - useDirectoryFlow: SnapshotSelectorHook -} +export type DirectoryPickingHooks = PropsHooks /** * Browser-private injected share (arrives via the register inject factory). * Data reads use the global framework hooks; these are the Host actions the * browsing region drives. */ -export type WorkspaceBrowserInjected = DirectoryPickingInjected & { +export type WorkspaceBrowserInjected = { hooks: DirectoryPickingInjected['hooks'] & { /** Current generation's Host description, bound by the slot renderer. */ hostDescription: HostDescriptionSource @@ -148,11 +145,7 @@ export type WorkspaceBrowserProps = & PropsRenderSlots<'sidebar.workspaces.directoryFlow'> & PropsStore> & Omit - & DirectoryPickingHooks - & { - /** Selector hook over the current generation's Host description. */ - useHostDescription: SnapshotSelectorHook - } + & PropsHooks & PropsLocale<'workspace'> /** diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 7b59ffd9ea..a8cfad6a10 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -96,6 +96,27 @@ function rerender(b: ReturnType, overrides: Partial { + it('workspace hover card shows a POSIX home descendant as ~', () => { + vi.useFakeTimers() + try { + mount({ + useWorkspaces: hook(workspaceState([{ + ...workspace('project', []), + path: '/home/u/Documents/project', + title: 'Project', + }])), + useHostDescription: selector => selector({ + version: '0', cwd: '/tmp', attachedSessions: 0, home: '/home/u', canOpenPath: false, + }), + }) + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('~/Documents/project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => { const pending = { ...workspaceState([]), From bf2e9e474cfb55dd7c7c2106c8a7d07b0e4cb1f4 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 21:17:41 +0800 Subject: [PATCH 094/137] refactor(subagent): simplify Claude cleanup ownership --- .../subagent/subagent-claude-code/src/run.ts | 26 ++++++------- .../tests/real-product.spec.ts | 3 -- .../tests/subagent-claude-code.spec.ts | 37 ++++++++++--------- 3 files changed, 30 insertions(+), 36 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 47bdca84d9..97ecc26339 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -262,7 +262,8 @@ export async function consumeClaudeQuery( * Close the official query, terminate the managed process tree, and wait for * the subprocess owner to prove it is gone. * @param query - official SDK query, when creation reached that point. - * @param child - shared-service handle that owns the CLI process tree. + * @param child - live shared-service handle that owns the CLI process tree; + * spawn-failed handles settle at the startup boundary instead. */ export async function disposeClaudeCodeChild( query: Pick | undefined, @@ -276,13 +277,11 @@ export async function disposeClaudeCodeChild( failures.push(thrown(error)) } - if (child.pid > 0) { - child.terminate() - try { - await child.waitForExit() - } catch (error: unknown) { - failures.push(thrown(error)) - } + child.terminate() + try { + await child.waitForExit() + } catch (error: unknown) { + failures.push(thrown(error)) } try { outcome = await child.done @@ -297,13 +296,10 @@ export async function disposeClaudeCodeChild( category: 'unknown', outcome, } as const - if (failures.length === 1) { - throw new ClaudeCodeFailure(facts, firstFailure) - } - throw new AggregateError( - failures.map(failure => new ClaudeCodeFailure(facts, failure)), - `subagent-claude-code: ${failureDiagnostic(facts)}`, - ) + const cause = failures.length === 1 + ? firstFailure + : new AggregateError(failures, 'Claude Code teardown failures') + throw new ClaudeCodeFailure(facts, cause) } } diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 4e8310958c..0141fb7311 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -322,9 +322,6 @@ describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 .toBe(process.platform === 'win32' ? realpathSync(claudeBin).toLowerCase() : realpathSync(claudeBin)) - expect(harness.spawnSpecs[0]?.env) - .not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE') - expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! expect(recorded.method).toBe('POST') diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 68b9536b23..ac5734e897 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -451,8 +451,6 @@ describe('task admission and package contracts', () => { const safeChild = fakeChild() const bypassChild = fakeChild() const spawnSpecs: SubprocessSpawnSpec[] = [] - vi.spyOn(ctx.subprocess, 'resolveExecutable') - .mockResolvedValue('/native/claude') vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { spawnSpecs.push(spec) return spec.env?.DSH_CLAUDE_INSTANCE === 'safe' @@ -463,7 +461,6 @@ describe('task admission and package contracts', () => { queryMock.mockImplementation(({ options }) => { queryOptions.push(options) options.spawnClaudeCodeProcess!(sdkSpawnOptions({ - command: options.pathToClaudeCodeExecutable!, cwd: options.cwd!, env: options.env!, signal: options.abortController!.signal, @@ -770,7 +767,6 @@ describe('official spawn projection', () => { expect(spec.argv).toEqual([ command, '--output-format', 'stream-json', ]) - expect(spec.env).not.toHaveProperty('DSH_CLAUDE_CODE_EXECUTABLE') }) it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { @@ -1629,28 +1625,33 @@ describe('query and process disposal', () => { 'unknown', { exitCode: 0, signal: null }, )) - await expect(waitAndClose).rejects.toBeInstanceOf(AggregateError) + const waitAndCloseError = await waitAndClose.then( + () => undefined, + (error: unknown) => error, + ) + const waitAndCloseCause = errorCause(waitAndCloseError) + expect(waitAndCloseCause).toBeInstanceOf(AggregateError) + expect((waitAndCloseCause as AggregateError).errors).toEqual([ + expect.objectContaining({ message: 'close boom' }), + expect.objectContaining({ message: 'wait boom' }), + ]) expect(waitFailure.terminate).toHaveBeenCalledOnce() const doneFailure = fakeChild({ - pid: -1, doneError: new Error('spawn boom'), }) - await expect(disposeClaudeCodeChild( + const directChildFailure = disposeClaudeCodeChild( { close: vi.fn() }, doneFailure.handle, - )).rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) - - const both = fakeChild({ - pid: -1, - doneError: new Error('spawn boom'), - }) - const bothFailures = disposeClaudeCodeChild( - { close: () => { throw new Error('close boom') } }, - both.handle, ) - await expect(bothFailures) + await expect(directChildFailure) .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) - await expect(bothFailures).rejects.toBeInstanceOf(AggregateError) + await expect(directChildFailure).rejects.not.toThrow('spawn boom') + const directChildError = await directChildFailure.then( + () => undefined, + (error: unknown) => error, + ) + expect(errorCause(directChildError)?.message).toBe('spawn boom') + expect(doneFailure.terminate).toHaveBeenCalledOnce() }) }) From ec3da3809a9d222557c95a29a9806c733bd5f1b8 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 21:29:32 +0800 Subject: [PATCH 095/137] refactor(subagent): align Claude teardown with live handles --- .../subagent/subagent-claude-code/src/run.ts | 7 +----- .../tests/subagent-claude-code.spec.ts | 23 +++---------------- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 97ecc26339..3b0a19073d 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -270,7 +270,6 @@ export async function disposeClaudeCodeChild( child: SubprocessHandle, ): Promise { const failures: Error[] = [] - let outcome: SubprocessOutcome | undefined try { query?.close() } catch (error: unknown) { @@ -283,11 +282,7 @@ export async function disposeClaudeCodeChild( } catch (error: unknown) { failures.push(thrown(error)) } - try { - outcome = await child.done - } catch (error: unknown) { - failures.push(thrown(error)) - } + const outcome = await child.done const firstFailure = failures[0] if (firstFailure !== undefined) { diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index ac5734e897..16ced04d72 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1535,7 +1535,7 @@ describe('run publication, cancellation, and settlement', () => { const constructionError = new Error( 'query construction failed with a live child', ) - const liveChildCleanupFailure = fakeChild({ doneError: cleanupError }) + const liveChildCleanupFailure = fakeChild({ waitForExitError: cleanupError }) queryMock.mockImplementationOnce(({ options }) => { options.spawnClaudeCodeProcess!(sdkSpawnOptions()) throw constructionError @@ -1545,7 +1545,7 @@ describe('run publication, cancellation, and settlement', () => { spawn: () => liveChildCleanupFailure.handle, }) await expect(liveCleanupFailure).rejects.toMatchObject({ - message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown')}`, + message: `subagent-claude-code: ${expectedFailureDiagnostic('query-start', 'unknown')}; subagent-claude-code: ${expectedFailureDiagnostic('teardown', 'unknown', { exitCode: 0, signal: null })}`, errors: [ expect.objectContaining({ cause: constructionError }), expect.objectContaining({ cause: cleanupError }), @@ -1611,7 +1611,7 @@ describe('query and process disposal', () => { expect(disposed).toBe(true) }) - it('reports wait, close, and direct-child failures without skipping cleanup', async () => { + it('reports close and tree-wait failures without skipping cleanup', async () => { const waitFailure = fakeChild({ waitForExitError: new Error('wait boom'), }) @@ -1636,22 +1636,5 @@ describe('query and process disposal', () => { expect.objectContaining({ message: 'wait boom' }), ]) expect(waitFailure.terminate).toHaveBeenCalledOnce() - - const doneFailure = fakeChild({ - doneError: new Error('spawn boom'), - }) - const directChildFailure = disposeClaudeCodeChild( - { close: vi.fn() }, - doneFailure.handle, - ) - await expect(directChildFailure) - .rejects.toThrow(expectedFailureDiagnostic('teardown', 'unknown')) - await expect(directChildFailure).rejects.not.toThrow('spawn boom') - const directChildError = await directChildFailure.then( - () => undefined, - (error: unknown) => error, - ) - expect(errorCause(directChildError)?.message).toBe('spawn boom') - expect(doneFailure.terminate).toHaveBeenCalledOnce() }) }) From c3a04de1cfbdc3649025d6ecbccad53af71826ef Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 21:41:06 +0800 Subject: [PATCH 096/137] test(subagent): model Claude spawn errors with failed handles --- .../subagent-claude-code/tests/subagent-claude-code.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 16ced04d72..cdcae485b2 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -802,7 +802,7 @@ describe('official spawn projection', () => { }) it('emits spawn errors', async () => { - const child = fakeChild() + const child = fakeChild({ pid: -1 }) const process = new ManagedClaudeCodeProcess(child.handle) const errorListener = vi.fn() const removed = vi.fn() From 1b9fd9eaa74eefa805480975cbac406d83ac2a51 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 22:41:32 +0800 Subject: [PATCH 097/137] test(subagent): trim redundant product evidence --- ...ude-code-and-codex-subagent-backends.i18n.yaml | 4 ++-- ...-04-claude-code-and-codex-subagent-backends.md | 8 ++++---- ...-claude-code-and-codex-subagent-backends.zh.md | 8 ++++---- .../tests/subagent-claude-code.spec.ts | 1 - .../subagent-codex/tests/subagent-codex.spec.ts | 15 +-------------- 5 files changed, 11 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 388c707cda..3593c3ceb1 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 89b605f88b40aea1cef8f0fb0a587ec70091d219 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 92955512da9bf2deda5ad20fe19f292cfab430dc +2026-08-04-claude-code-and-codex-subagent-backends.md: 9b47fcf49d47d2c3561245fa1e16ff8c5da0a35c +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: fcb1aac71be2da9d907ad67867c763e3051baec5 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 89b605f88b..9b47fcf49d 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -38,7 +38,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro Before publication, the provider validates a non-empty text-only task, starts the managed app-server in the parent workspace, completes `initialize` → `initialized`, maps the resolved mode into official `thread/start` fields, and creates an `ephemeral: true` thread. The fixed app-server argv contains no mode or task text. The published run owns exactly one `turn/start`; its thread and turn ids remain private and are never persisted in the parent Session. -`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The Provider preserves every Codex 0.147.0 string and object `codexErrorInfo` variant in the shared safe diagnostic, including numeric HTTP status on the four connection/stream variants while omitting `turnKind`. It derives `initialize`, `thread-start`, `turn-start`, `turn`, `process`, and `teardown` from the current operation, preserves available exit code and signal, and uses `unknown` outside the fixed schema. `contextWindowExceeded` remains `max-tokens`; every other category remains `error`, `cyberPolicy` does not become `refusal`, and local cancellation remains `aborted` without a failure diagnostic. +`turn/completed` is the authoritative remote terminal fact. The latest `agentMessage` with `phase: "final_answer"` wins, and that selected message must contain nonblank text. When the product emits no explicit final phase, the latest message with `phase: null` is the compatibility fallback and must likewise be nonblank; commentary never replaces either answer. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns Codex error-info categories, HTTP status, lifecycle stages, process outcomes, and stop-reason preservation. Local cancellation remains `aborted` without a failure diagnostic. For command and file approvals, the unattended wire selects a non-approval decision offered by the request, preferring `cancel`; the stable 0.147.0 request shape without an offered-decision list falls back to `decline`. It grants no requested permissions for the turn, answers user-input requests with no answers, and declines MCP elicitation. It records safe categories for those requests, declined command/file items, and `sandboxError`. Codex emits some early `never` rejections and sandbox violations only on structured stderr, so the Provider pipes and forwards stderr unchanged while matching two fixed signatures in a bounded per-run tail; raw stderr never enters the diagnostic. A request with no legal unattended response, or any unknown server request, fails the run instead of waiting for a user interface the provider does not supply. @@ -52,7 +52,7 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp The public configuration contains a non-empty `providerName`, an explicit `env` overlay, a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`, and a five-value native `permissionMode` that defaults to `dontAsk`. Each named instance retains those resolved values for its own runs. Each run creates its own `AbortController`, sets `persistSession: false`, disables `AskUserQuestion`, and passes the resolved mode to the SDK; only `bypassPermissions` receives the SDK's explicit dangerous confirmation. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. Remaining permission prompts are denied, MCP elicitation is declined, and blocking dialogs fail closed instead of waiting for a user interface the provider does not own. -The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. Every other result remains `error`, but its bounded diagnostic preserves the four exact SDK error subtypes, fixed categories for invalid success and missing result, a safe `unknown` fallback, the current `query-start`, `query-run`, `process`, or `teardown` stage, and any observed exit code and signal. A contributing permission decision follows that structured failure line. SDK turn, budget, and structured-output limits are not token-window facts, and the SDK exposes no native refusal terminal, so this provider produces neither `max-tokens` nor `refusal`. Local cancellation wins and becomes `aborted` without either diagnostic fact. +The provider publishes only after both the SDK `Query` and a live managed CLI handle exist. It consumes the complete SDK stream and completes only when a `result` message has `subtype: "success"`, `is_error: false`, and a nonblank `result`, and the iterator then ends normally. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns every non-success category, stage, process outcome, and its ordering with a contributing permission decision. Local cancellation wins and becomes `aborted` without either diagnostic fact. Startup rollback and published disposal close the SDK query, abort the per-run controller, invoke shared process-tree termination, and wait for whole-tree exit. `Query.close()` expresses graceful protocol intent but does not replace the subprocess owner's exit proof. An unpublished failure exposes only fixed `query-start` facts; a published process failure can expose its independent exit code and signal; an independent cleanup rejection exposes `teardown`. Original SDK, Host, and cleanup errors remain on internal cause chains and logs rather than entering the diagnostic. @@ -62,11 +62,11 @@ The credentialed Claude Code e2e uses the official DeepSeek Claude Code contract Each product owns branch-complete package tests, a required keyless real-product spec, a Loader composition e2e, and a credentialed DeepSeek e2e. The keyless product tier uses the exact official distribution under test, a non-empty fake product key, an isolated temporary workspace and product home, and a loopback fixed-answer model. Missing product requests, wrong authentication, altered task text, a non-exact answer, a skipped real product, or a surviving managed handle fails the required test. The Codex Loader fixture exposes two named Codex instances and tools; the Claude Code Loader fixture exposes the default Codex tool plus two named Claude Code instances and tools. Both fixtures include generic Job controls and start neither product process. The credentialed tier starts the same production provider and real product with a runtime-only key, requires a unique nonce from the fixed official DeepSeek service, and proves quiescence again; it self-skips only when a local operator supplied no key, while trusted CI preflights the secret. -The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Generated schema evidence and package tests pin all sixteen error-info variants, HTTP status, six stages, process outcomes, stop-reason mapping, unknown fallback, sanitization, permission ordering, cancellation, concurrency, and cleanup aggregation. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, thread-level `never` overriding ambient `on-request`, automatic-review startup, unattended command rejection with safe diagnostic and no file side effect, a real `internalServerError`, explicit dangerous-bypass writing in suite-owned temporary storage, local cancellation, process/protocol failure, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, while Provider tests keep the public startup error on fixed safe facts. Two named instances retain separate environments and native modes. Production never resolves a host `codex` from `PATH`. +The Codex evidence pins `@openai/codex@0.147.0`, `codex-cli 0.147.0`, and all six optional platform aliases. Its real-product spec observes the package-local wrapper argv, exact Bearer key, original task, byte-exact final answer, native permission modes, explicit dangerous-bypass writing in suite-owned temporary storage, and wrapper/native whole-tree exit. An isolated wrapper fixture proves missing-payload failure without host fallback, two named instances retain separate environments and modes, and production never resolves a host `codex` from `PATH`. The [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns schema, failure, process-outcome, and final presentation evidence. The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and the identities and versions of all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, an inherited interactive host setting overridden by the safe Provider mode, denied and bypassed writes in suite-owned temporary directories, a real `error_max_turns` result, a process exit with its outcome, safe permission diagnostics, local cancellation, and whole-tree exit. Package tests pin the complete SDK error union, all four stages, unknown fallback, independent code and signal fields, sanitization, success and cancellation omission, and concurrent-run isolation. They also prove that production never resolves host `PATH`, omits the executable override, forwards the SDK-selected Windows `claude.exe` without a batch shim, and reports a missing payload as safe `query-start` / `unknown` facts while retaining the native SDK error only on the internal cause chain and Host log. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions. Loader coverage resolves both products through their optional Bundle patches while starting neither product. +The Claude Code evidence pins Agent SDK 0.3.220, Claude Code 2.1.220, and all eight SDK platform packages. Its real-product spec lets the SDK select the installed payload, asserts that the shared subprocess argv begins with that package's native CLI, and observes the exact `x-api-key`, original task, byte-exact final answer, native permission modes, suite-owned denied and bypassed writes, and whole-tree exit. Package tests prove that production never resolves host `PATH`, omits the executable override, and forwards the SDK-selected Windows `claude.exe` without a batch shim. This evidence proves the pinned official SDK/CLI integration rather than compatibility with independently installed Claude versions; the [structured failure-facts decision](2026-08-18-product-subagent-failure-facts.md) owns failure and process-outcome evidence. Loader coverage resolves both products through their optional Bundle patches while starting neither product. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 92955512da..fcb1aac71b 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -38,7 +38,7 @@ configured tool -> dsh-tool-subagent -> ctx.subagents -> product provider -> pro 发布前,提供方会验证非空的纯文本任务,在父级工作区中启动受管的 app-server,完成 `initialize` → `initialized` 握手,把已解析模式映射为官方 `thread/start` 字段,并创建一个 `ephemeral: true` 线程。固定 app-server argv 不包含模式或任务文本。已发布的运行只拥有一次 `turn/start`;其线程 ID 与轮次 ID 保持私有,绝不会持久化到父会话。 -`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。提供方会把 Codex 0.147.0 的每种字符串与对象 `codexErrorInfo` variant 保留进共享安全诊断;四种连接/stream variant 会保留数值 HTTP status,但不会公开 `turnKind`。它会从当前操作派生 `initialize`、`thread-start`、`turn-start`、`turn`、`process` 和 `teardown`,保留可用的退出码与信号,并对固定 schema 之外的值使用 `unknown`。`contextWindowExceeded` 仍是 `max-tokens`;其他类别仍是 `error`,`cyberPolicy` 不会成为 `refusal`,本地取消仍是 `aborted` 且不附带失败诊断。 +`turn/completed` 是权威的远端终止事实。以最后一条带有 `phase: "final_answer"` 的 `agentMessage` 为准,且选中的消息必须包含非空白文本。若产品没有发出明确的最终阶段,则以最后一条 `phase: null` 的消息作为兼容性回退,该消息也必须包含非空白文本;过程说明绝不会取代上述任一答案。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责 Codex error-info 类别、HTTP status、生命周期阶段、进程结果与终止原因保持。本地取消仍是 `aborted` 且不附带失败诊断。 对于命令与文件审批,无人值守的协议连接会从请求给出的决策选项中选择一项不予批准的决策,并优先选择 `cancel`;稳定的 0.147.0 请求形态没有决策选项列表,因此回退到 `decline`。它不授予该轮次请求的任何权限,不向用户输入请求提供任何答案,并拒绝 MCP elicitation。它会记录这些请求、被拒绝的命令/文件 item 与 `sandboxError` 的安全类别。Codex 的部分早期 `never` 拒绝和 sandbox violation 只写入结构化 stderr,因此提供方会 pipe 并原样转发 stderr,同时在每次运行的有界尾部中匹配两个固定签名;原始 stderr 绝不会进入诊断。若请求在无人值守模式下没有合法响应,或是未知服务器请求,此次运行就会失败,而不会等待本提供方没有提供的用户界面。 @@ -52,7 +52,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 公开配置包含非空的 `providerName`、显式的 `env` 覆盖项、须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`,以及默认使用 `dontAsk` 的五值原生 `permissionMode`。每个命名实例会为自己的运行保留这些已解析值。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false`、禁用 `AskUserQuestion`,并把已解析模式传给 SDK;只有 `bypassPermissions` 会取得 SDK 的显式危险确认。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。其余权限提示会被拒绝,MCP elicitation 会被拒绝,阻塞对话会快速失败,而不会等待本提供方不负责的用户界面。 -只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。其他所有结果仍成为 `error`,但其有界诊断会保留四种准确 SDK 错误子类型、标记为错误的成功消息与结果缺失所对应的固定类别、安全的 `unknown` 回退、当前 `query-start`、`query-run`、`process` 或 `teardown` 阶段,以及已观测到的退出码和信号。若权限决定也参与失败,它会跟在结构化失败行之后。SDK 的轮次、预算和结构化输出限制不表示 token 窗口耗尽,而且 SDK 没有原生的拒绝终止状态,因此本提供方不会产生 `max-tokens` 或 `refusal`。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。 +只有在 SDK `Query` 与受管的活动 CLI 句柄都已存在后,提供方才会发布运行。它会消费完整的 SDK 流;只有 `result` 消息具有 `subtype: "success"`、`is_error: false` 和非空白 `result`,且迭代器随后正常结束时,运行才会完成。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责所有非成功类别、阶段、进程结果,以及它们与参与失败的权限决定之间的顺序。本地取消会胜出并成为 `aborted`,且不附带这两类诊断事实。 启动回滚和已发布运行的资源释放都会关闭 SDK query、中止该次运行的控制器、调用共享的进程树终止机制,并等待整棵进程树退出。`Query.close()` 表达优雅的协议关闭意图,但不能取代子进程责任方的退出证明。未发布失败只公开固定的 `query-start` 事实;已发布进程失败可以分别公开退出码与信号;独立清理拒绝则公开 `teardown`。原始 SDK、Host 与清理错误只保留在内部 cause 链和日志中,不进入诊断。 @@ -62,11 +62,11 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 每个产品都负责覆盖所有分支的包测试、一项必跑的无密钥真实产品测试、一项 Loader 组合 e2e 和一项带密钥 DeepSeek e2e。无密钥产品层级使用被测的确切官方发行版、非空的伪产品密钥、隔离的临时工作区与产品主目录,以及能返回固定答案的回环模型。产品请求缺失、身份验证错误、任务文本被改动、答案不完全一致、真实产品被跳过或受管句柄仍存活,都会使这项必跑测试失败。Codex Loader fixture 会公开两个命名 Codex 实例与工具;Claude Code Loader fixture 会公开默认 Codex 工具以及两个命名 Claude Code 实例与工具。两个 fixture 都包含通用 Job 控制工具,而且不会启动任何产品进程。带密钥层级会使用仅在运行时提供的密钥启动同一生产提供方与真实产品,要求从固定的 DeepSeek 官方服务取得唯一随机数,并再次证明完全停稳;仅当本地操作者未提供密钥时才会自行跳过,而受信任的 CI 会预检该 secret。 -Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。生成的 schema 证据与包测试会固定全部十六种 error-info variant、HTTP status、六个阶段、进程结果、终止原因映射、unknown 回退、脱敏、权限顺序、取消、并发与清理聚合。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、线程级 `never` 对环境中 `on-request` 的覆盖、自动评审启动、带安全诊断且不产生文件副作用的无人值守命令拒绝、真实 `internalServerError`、测试拥有临时存储中的显式危险绕过写入、本地取消、进程/协议失败以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,而提供方测试会让公开启动错误保持固定安全事实。两个命名实例会保留彼此独立的环境与原生模式。生产环境不会从 `PATH` 解析宿主 `codex`。 +Codex 证据会锁定 `@openai/codex@0.147.0`、`codex-cli 0.147.0` 与六个平台 alias。其真实产品测试会观测包内 wrapper argv、确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有临时存储中的显式危险绕过写入,以及 wrapper/原生整棵进程树退出。独立 wrapper fixture 会证明载荷缺失时不回退宿主命令,两个命名实例会保留彼此独立的环境与模式,生产环境也不会从 `PATH` 解析宿主 `codex`。[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责 schema、失败、进程结果与最终呈现证据。 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220,以及八个 SDK 平台包的身份与版本。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、安全提供方模式对继承的交互式宿主设置的覆盖、测试所拥有临时目录中的拒绝写入与 bypass 写入、真实的 `error_max_turns` 结果、携带进程结果的提前退出、安全权限诊断、本地取消和整棵进程树退出。包测试会固定完整 SDK 错误联合、四个阶段、unknown 回退、相互独立的退出码与信号字段、脱敏、成功与取消时省略诊断,以及并发运行隔离;还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖、直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim,并在载荷缺失时公开安全的 `query-start` / `unknown` 事实,同时只在内部 cause 链与 Host 日志中保留原生 SDK 错误。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 +Claude Code 证据会锁定 Agent SDK 0.3.220、Claude Code 2.1.220 与八个 SDK 平台包。真实产品测试会让 SDK 选择已安装载荷,断言共享子进程 argv 以该包的原生 CLI 开头,并观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、原生权限模式、测试拥有范围内的拒绝写入与 bypass 写入,以及整棵进程树退出。包测试还会证明生产运行从不解析宿主 `PATH`、省略可执行文件覆盖,并直接转发 SDK 所选的 Windows `claude.exe` 而不经过 batch shim。这项证据证明锁定的官方 SDK/CLI 集成,而不证明与独立安装的 Claude 版本兼容;[结构化失败事实决策](2026-08-18-product-subagent-failure-facts.md)负责失败与进程结果证据。Loader 覆盖会通过各自的可选 Bundle patch 解析两个产品,且不会启动任一产品。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index cdcae485b2..cc37910a8e 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1182,7 +1182,6 @@ describe('run publication, cancellation, and settlement', () => { const outcomes: SubprocessOutcome[] = [ { exitCode: 23, signal: null }, { exitCode: null, signal: 'SIGABRT' }, - { exitCode: 23, signal: 'SIGABRT' }, { exitCode: null, signal: null }, ] for (const outcome of outcomes) { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 222b29c26f..e9d1d6f4b7 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -415,11 +415,6 @@ describe('task admission and package contracts', () => { expect(JSON.stringify(rows)).not.toContain('tool-subagent') }) - it('uses only the official package-declared wrapper for app-server', () => { - expect(codexAppServerArgv()[0]).toBe(process.execPath) - expect(codexAppServerArgv().slice(2)).toEqual(['app-server', '--stdio']) - }) - it('accepts one or more text blocks and rejects empty or non-text tasks', () => { expect(textTask([ { type: 'text', text: 'one' }, @@ -1666,7 +1661,6 @@ describe('run lifecycle and quiescence', () => { const outcomes: SubprocessOutcome[] = [ { exitCode: 9, signal: null }, { exitCode: null, signal: 'SIGABRT' }, - { exitCode: 9, signal: 'SIGABRT' }, { exitCode: null, signal: null }, ] for (const outcome of outcomes) { @@ -1689,7 +1683,7 @@ describe('run lifecycle and quiescence', () => { await run.dispose().catch(() => {}) } { - const outcome = { exitCode: 17, signal: 'SIGABRT' } as const + const outcome = { exitCode: 17, signal: null } as const const child = fakeChild({ exitOnTerminate: false }) const { run, turnStart } = await publishRun(child, undefined, { disposeGraceMs: 100, @@ -2229,13 +2223,6 @@ describe('disposeCodexChild', () => { expect(child.waitForExit).not.toHaveBeenCalled() }) - it('accepts absent stdin', async () => { - const child = fakeChild() - const handle = { ...child.handle, stdin: undefined } - const wire = defaultWire(child) - await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined() - }) - it('reports tree-wait failure with safe teardown facts', async () => { const child = fakeChild({ waitForExitError: new Error('SECRET_TOKEN wait failure'), From cede4fdb76cadb6c4adee3c5985ad01d43333372 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 22:48:24 +0800 Subject: [PATCH 098/137] test(subagent): use valid process outcomes --- .../tests/subagent-claude-code.spec.ts | 4 ++-- .../subagent/subagent-codex/tests/subagent-codex.spec.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index cc37910a8e..3298e07037 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -1396,7 +1396,7 @@ describe('run publication, cancellation, and settlement', () => { queryMock.mockImplementationOnce(({ options }) => { factoryController = options.abortController options.spawnClaudeCodeProcess!(sdkSpawnOptions()) - spawned.settle({ exitCode: 17, signal: 'SIGABRT' }) + spawned.settle({ exitCode: 17, signal: null }) throw new Error('query construction failed') }) const factoryFailure = startClaudeCodeRun(request(), { @@ -1409,7 +1409,7 @@ describe('run publication, cancellation, and settlement', () => { await expect(factoryFailure).rejects.toThrow(expectedFailureDiagnostic( 'query-start', 'unknown', - { exitCode: 17, signal: 'SIGABRT' }, + { exitCode: 17, signal: null }, )) await expect(factoryFailure).rejects.not.toThrow('query construction failed') expect(spawnSpecs).toHaveLength(1) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index e9d1d6f4b7..150c3ee4d0 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1708,7 +1708,7 @@ describe('run lifecycle and quiescence', () => { codexErrorInfo: 'other', })) child.fromChild.end() - child.settle({ exitCode: 17, signal: 'SIGABRT' }) + child.settle({ exitCode: 17, signal: null }) await expect(run.result).resolves.toEqual({ output: [], diagnostic: expectedFailureDiagnostic('turn', 'other'), @@ -1725,7 +1725,7 @@ describe('run lifecycle and quiescence', () => { turnCompleted('completed'), ) child.fromChild.end() - child.settle({ exitCode: 17, signal: 'SIGABRT' }) + child.settle({ exitCode: 17, signal: null }) await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed', @@ -1967,11 +1967,11 @@ describe('run lifecycle and quiescence', () => { }) await exitedThreadChild.peer.nextMethod('initialized') await exitedThreadChild.peer.nextMethod('thread/start') - exitedThreadChild.settle({ exitCode: 17, signal: 'SIGABRT' }) + exitedThreadChild.settle({ exitCode: null, signal: 'SIGABRT' }) await expect(exitedThreadStarting).rejects.toThrow(expectedFailureDiagnostic( 'thread-start', 'unknown', - { outcome: { exitCode: 17, signal: 'SIGABRT' } }, + { outcome: { exitCode: null, signal: 'SIGABRT' } }, )) const stderrChild = fakeChild() From 7fb9de99b5bace643816888f5af6736da8c15187 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 23:06:14 +0800 Subject: [PATCH 099/137] fix(subagent): close Codex failure result gaps --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/subagent/subagent-codex/src/index.ts | 18 +++++- packages/subagent/subagent-codex/src/run.ts | 17 ++++- .../tests/subagent-codex.spec.ts | 63 ++++++++++++++++++- 6 files changed, 96 insertions(+), 10 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8cd5f2a748..bbb667e729 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: 127e9bebe677f9d122699d02dedca35002b047af -config-catalog.zh.md: 547116ef2c279d43c9d07f2c0d59f1e2d8e1abe5 +config-catalog.md: 1a1e376a0f3f54fd602510cfa70ad067abd385fa +config-catalog.zh.md: 46a08e13283c8b994f42597c3551478aa40717e7 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 127e9bebe6..1a1e376a0f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2145,7 +2145,7 @@ export type CodexPermissionMode = | 'dangerously-bypass-approvals-and-sandbox' ``` -Source: [`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts) +Source: [`packages/subagent/subagent-codex/src/index.ts:36`](../packages/subagent/subagent-codex/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 547116ef2c..46a08e1328 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2147,7 +2147,7 @@ export type CodexPermissionMode = | 'dangerously-bypass-approvals-and-sandbox' ``` -来源:[`packages/subagent/subagent-codex/src/index.ts:35`](../packages/subagent/subagent-codex/src/index.ts) +来源:[`packages/subagent/subagent-codex/src/index.ts:36`](../packages/subagent/subagent-codex/src/index.ts) diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index d94197edf7..79fbdab078 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -21,6 +21,7 @@ import { CODEX_PERMISSION_MODES, DEFAULT_CODEX_PERMISSION_MODE, DEFAULT_DISPOSE_GRACE_MS, + codexStartupFailure, startCodexRun, type CodexPermissionMode, type CodexRunSpec, @@ -73,12 +74,23 @@ class CodexProvider implements SubagentProvider { 'subagent-codex: no working directory for the child — delegate from a parent session that has one', ) } - const spec: CodexRunSpec = { - cwd: resolveChildCwd( + let cwd: string + try { + cwd = resolveChildCwd( 'subagent-codex', undefined, parentCwd, - ), + ) + } catch (error: unknown) { + if (request.signal.aborted) { + throw new Error( + 'subagent-codex: request was aborted before app-server startup', + ) + } + throw codexStartupFailure(error) + } + const spec: CodexRunSpec = { + cwd, permissionMode: this.config.permissionMode, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index d8e6265e02..7f7b0aac97 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -113,6 +113,18 @@ class CodexRunFailure extends Error { } } +/** + * Hide an unpublished Host failure behind fixed safe startup facts. + * @param cause Original Host failure retained for internal diagnostics. + * @returns A startup failure whose message contains only fixed safe facts. + */ +export function codexStartupFailure(cause: unknown): Error { + return new CodexRunFailure({ + stage: 'initialize', + category: 'unknown', + }, cause) +} + /** * Fixed package-local app-server command, independent of the host `PATH`. * @returns Node, the official wrapper, and the fixed app-server arguments. @@ -346,6 +358,9 @@ export async function startCodexRun( try { const terminal = await wire.runTurn(texts, runAbort.signal) if (terminal.stopReason === 'completed') return terminal + // Let stderr already queued with the terminal frame contribute its + // fixed permission fact before the non-completed result is snapshotted. + await new Promise((resolve) => { setImmediate(resolve) }) const facts = wire.collectFailure() return { ...terminal, diagnostic: recordFailureDiagnostic(facts) } } catch (error: unknown) { @@ -360,7 +375,7 @@ export async function startCodexRun( ) { try { const exited = await child.waitForExit( - AbortSignal.timeout(spec.disposeGraceMs), + AbortSignal.timeout(Math.ceil(spec.disposeGraceMs)), ) if (exited) await child.done } catch { diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 150c3ee4d0..b4bfbf741c 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1656,6 +1656,26 @@ describe('run lifecycle and quiescence', () => { } }) + it('includes a queued stderr permission fact in a max-token result', async () => { + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + setImmediate(() => { + child.stderr.write('approval policy is Never; reject command') + }) + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + codexErrorInfo: 'contextWindowExceeded', + }), + ) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial answer' }], + diagnostic: `${expectedFailureDiagnostic('turn', 'contextWindowExceeded')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, + stopReason: 'max-tokens', + }) + await run.dispose() + }) + it('flattens child exit and protocol failures after publication', async () => { const errors: string[] = [] const outcomes: SubprocessOutcome[] = [ @@ -1686,11 +1706,15 @@ describe('run lifecycle and quiescence', () => { const outcome = { exitCode: 17, signal: null } as const const child = fakeChild({ exitOnTerminate: false }) const { run, turnStart } = await publishRun(child, undefined, { - disposeGraceMs: 100, + disposeGraceMs: 0.5, }) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + vi.spyOn(child.handle, 'waitForExit').mockImplementationOnce(async (signal?: AbortSignal) => { + expect(signal).toBeDefined() + child.settle(outcome) + return true + }) child.fromChild.emit('end') - setTimeout(() => { child.settle(outcome) }, 5) await expect(run.result).resolves.toEqual({ output: [], diagnostic: expectedFailureDiagnostic('process', 'process-exit', { @@ -2117,6 +2141,41 @@ describe('run lifecycle and quiescence', () => { permissionMode: 'approve-for-me', disposeGraceMs: 25, }) + + const invalidCwdParent = { + id: 'parent-with-invalid-cwd', + session: { header: { cwd: 'relative/SECRET_TOKEN' } }, + } as unknown as Agent + const invalidCwdError: unknown = await ctx.subagents.start('codex-diagnostic', { + prompt: [{ type: 'text', text: 'task' }], + parent: invalidCwdParent, + signal: new AbortController().signal, + }).then( + () => undefined, + (error: unknown) => error, + ) + expect(invalidCwdError).toBeInstanceOf(Error) + if (!(invalidCwdError instanceof Error)) { + throw new Error('expected safe invalid-cwd failure') + } + expect(invalidCwdError.message).toContain( + expectedFailureDiagnostic('initialize', 'unknown'), + ) + expect(invalidCwdError.message).not.toContain('relative/SECRET_TOKEN') + expect(invalidCwdError.cause).toBeInstanceOf(Error) + expect((invalidCwdError.cause as Error).message) + .toContain('relative/SECRET_TOKEN') + expect(spawn).not.toHaveBeenCalled() + + const invalidCwdAbort = new AbortController() + invalidCwdAbort.abort(new Error('cancel invalid cwd startup')) + await expect(ctx.subagents.start('codex-diagnostic', { + prompt: [{ type: 'text', text: 'task' }], + parent: invalidCwdParent, + signal: invalidCwdAbort.signal, + })).rejects.toThrow('aborted before app-server startup') + expect(spawn).not.toHaveBeenCalled() + const starting = ctx.subagents.start('codex-diagnostic', { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent, From 8debb798d8c2d82d671183bbb07e05f2961f5291 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Tue, 18 Aug 2026 23:59:43 +0800 Subject: [PATCH 100/137] fix(subagent): bound Codex process settlement --- packages/subagent/subagent-codex/src/run.ts | 43 +++++++++++++++---- .../tests/subagent-codex.spec.ts | 43 ++++++++++++++++++- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 7f7b0aac97..8d38d2daba 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -189,6 +189,12 @@ export async function disposeCodexChild( wire.close() if (child.pid > 0) { + let outcome: SubprocessOutcome | undefined + void child.done.then( + (value) => { outcome = value }, + /* v8 ignore next -- a positive pid excludes spawn-level done rejection. */ + () => {}, + ) try { child.stdin?.end() } catch { @@ -198,7 +204,6 @@ export async function disposeCodexChild( try { await child.waitForExit() } catch (error: unknown) { - const outcome = await child.done throw new CodexRunFailure({ stage: 'teardown', category: 'unknown', @@ -294,8 +299,8 @@ export async function startCodexRun( throw new CodexRunFailure(processFailureFacts, thrown(error)) }, ) - // A normal post-result dispose also closes the process. Keep that expected - // late rejection observed after the result race has already settled. + // A normal post-result dispose also closes the process. Keep its expected + // late rejection observed when the terminal result settles first. processFailure.catch(() => {}) const runAbort = new AbortController() @@ -316,12 +321,17 @@ export async function startCodexRun( } catch (error: unknown) { request.signal.removeEventListener('abort', onAbort) const cancelledBeforeCleanup = runAbort.signal.aborted + if (!(error instanceof CodexRunFailure) && !cancelledBeforeCleanup) { + // Node reports stdout EOF before the child close that owns its outcome. + // Let an already-exiting process publish those facts before rollback. + await new Promise((resolve) => { setImmediate(resolve) }) + } const failure = new CodexRunFailure({ stage: startupStage, category: 'unknown', outcome: error instanceof CodexRunFailure ? error.facts.outcome - : undefined, + : processFailureFacts?.outcome, }, thrown(error)) try { await disposeProcess() @@ -353,10 +363,21 @@ export async function startCodexRun( : `${failure}\n${permission}` return diagnostic } + const publishedProcessFailure = processFailure.catch( + async (error: unknown): Promise => { + // Frames already queued by the exiting app-server remain authoritative. + // One I/O turn lets them settle before process exit ends the run. + await new Promise((resolve) => { setImmediate(resolve) }) + throw error + }, + ) const result: Promise = settleRunResult({ attempt: async () => { try { - const terminal = await wire.runTurn(texts, runAbort.signal) + const terminal = await Promise.race([ + wire.runTurn(texts, runAbort.signal), + publishedProcessFailure, + ]) if (terminal.stopReason === 'completed') return terminal // Let stderr already queued with the terminal frame contribute its // fixed permission fact before the non-completed result is snapshotted. @@ -382,11 +403,15 @@ export async function startCodexRun( // The wire failure remains authoritative when exit observation fails. } } - const facts = endedBeforeTerminal && processFailureFacts !== undefined - ? processFailureFacts - : wire.collectFailure() + const facts = error instanceof CodexRunFailure + ? error.facts + : endedBeforeTerminal && processFailureFacts !== undefined + ? processFailureFacts + : wire.collectFailure() recordFailureDiagnostic(facts) - throw new CodexRunFailure(facts, thrown(error)) + throw error instanceof CodexRunFailure + ? error + : new CodexRunFailure(facts, thrown(error)) } }, collectOutput, diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index b4bfbf741c..29fba86745 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1689,7 +1689,6 @@ describe('run lifecycle and quiescence', () => { onError: (error) => { errors.push(error.message) }, }) child.settle(outcome) - child.fromChild.emit('end') await expect(run.result).resolves.toEqual({ output: [], diagnostic: expectedFailureDiagnostic('process', 'process-exit', { @@ -1998,6 +1997,28 @@ describe('run lifecycle and quiescence', () => { { outcome: { exitCode: null, signal: 'SIGABRT' } }, )) + const eofBeforeCloseChild = fakeChild({ exitOnTerminate: false }) + const eofBeforeCloseStarting = startCodexRun( + request(), + runSpec(eofBeforeCloseChild), + ) + const eofBeforeCloseInitialize = await eofBeforeCloseChild.peer + .nextMethod('initialize') + eofBeforeCloseChild.peer.respond(eofBeforeCloseInitialize, { + userAgent: 'codex-cli 0.147.0', + }) + await eofBeforeCloseChild.peer.nextMethod('initialized') + await eofBeforeCloseChild.peer.nextMethod('thread/start') + eofBeforeCloseChild.fromChild.emit('end') + setImmediate(() => { + eofBeforeCloseChild.settle({ exitCode: 23, signal: null }) + }) + await expect(eofBeforeCloseStarting).rejects.toThrow( + expectedFailureDiagnostic('thread-start', 'unknown', { + outcome: { exitCode: 23, signal: null }, + }), + ) + const stderrChild = fakeChild() const stderrStarting = startCodexRun(request(), runSpec(stderrChild)) const stderrInitialize = await stderrChild.peer.nextMethod('initialize') @@ -2295,4 +2316,24 @@ describe('disposeCodexChild', () => { )) await expect(disposal).rejects.not.toThrow('SECRET_TOKEN') }) + + it('does not wait for a pending process outcome after tree observation fails', async () => { + const child = fakeChild({ + exitOnTerminate: false, + waitForExitError: new Error('SECRET_TOKEN wait failure'), + }) + const wire = defaultWire(child) + let disposalError: unknown + const disposal = disposeCodexChild(wire, child.handle).catch( + (error: unknown) => { disposalError = error }, + ) + await nextTask() + expect(disposalError).toBeInstanceOf(Error) + expect(String(disposalError)).toContain( + expectedFailureDiagnostic('teardown', 'unknown'), + ) + expect(String(disposalError)).not.toContain('SECRET_TOKEN') + child.settle() + await disposal + }) }) From 34d313693dc40599f5d059669e133a396d6f3944 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 00:29:40 +0800 Subject: [PATCH 101/137] test(subagent): pin terminal exit ordering --- .../subagent/subagent-codex/tests/subagent-codex.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 29fba86745..0d08103ae6 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1727,10 +1727,11 @@ describe('run lifecycle and quiescence', () => { const child = fakeChild({ exitOnTerminate: false }) const { run, turnStart } = await publishRun(child) child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) - child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { - codexErrorInfo: 'other', - })) - child.fromChild.end() + setImmediate(() => { + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + codexErrorInfo: 'other', + })) + }) child.settle({ exitCode: 17, signal: null }) await expect(run.result).resolves.toEqual({ output: [], From 72884ec430e131581891f17d50625a62e79d3641 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 01:10:29 +0800 Subject: [PATCH 102/137] fix(subagent): merge terminal and process facts --- packages/subagent/subagent-codex/src/run.ts | 10 ++++++++-- .../tests/subagent-codex.spec.ts | 19 +++++++++++-------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 8d38d2daba..9db5d7086c 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -363,6 +363,12 @@ export async function startCodexRun( : `${failure}\n${permission}` return diagnostic } + const withProcessOutcome = (facts: CodexFailureFacts): CodexFailureFacts => { + const outcome = processFailureFacts?.outcome + return outcome === undefined + ? facts + : { ...facts, outcome } + } const publishedProcessFailure = processFailure.catch( async (error: unknown): Promise => { // Frames already queued by the exiting app-server remain authoritative. @@ -382,7 +388,7 @@ export async function startCodexRun( // Let stderr already queued with the terminal frame contribute its // fixed permission fact before the non-completed result is snapshotted. await new Promise((resolve) => { setImmediate(resolve) }) - const facts = wire.collectFailure() + const facts = withProcessOutcome(wire.collectFailure()) return { ...terminal, diagnostic: recordFailureDiagnostic(facts) } } catch (error: unknown) { // Give stderr data already queued in Node one turn to reach the wire @@ -407,7 +413,7 @@ export async function startCodexRun( ? error.facts : endedBeforeTerminal && processFailureFacts !== undefined ? processFailureFacts - : wire.collectFailure() + : withProcessOutcome(wire.collectFailure()) recordFailureDiagnostic(facts) throw error instanceof CodexRunFailure ? error diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 0d08103ae6..296c8afa09 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1661,16 +1661,17 @@ describe('run lifecycle and quiescence', () => { child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) setImmediate(() => { child.stderr.write('approval policy is Never; reject command') + child.peer.send( + agentMessage('partial answer', null), + turnCompleted('failed', 'turn-1', 'thread-1', { + codexErrorInfo: 'contextWindowExceeded', + }), + ) }) - child.peer.send( - agentMessage('partial answer', null), - turnCompleted('failed', 'turn-1', 'thread-1', { - codexErrorInfo: 'contextWindowExceeded', - }), - ) + child.settle({ exitCode: 17, signal: null }) await expect(run.result).resolves.toEqual({ output: [{ type: 'text', text: 'partial answer' }], - diagnostic: `${expectedFailureDiagnostic('turn', 'contextWindowExceeded')}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, + diagnostic: `${expectedFailureDiagnostic('turn', 'contextWindowExceeded', { outcome: { exitCode: 17, signal: null } })}\nCodex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval`, stopReason: 'max-tokens', }) await run.dispose() @@ -1735,7 +1736,9 @@ describe('run lifecycle and quiescence', () => { child.settle({ exitCode: 17, signal: null }) await expect(run.result).resolves.toEqual({ output: [], - diagnostic: expectedFailureDiagnostic('turn', 'other'), + diagnostic: expectedFailureDiagnostic('turn', 'other', { + outcome: { exitCode: 17, signal: null }, + }), stopReason: 'error', }) await run.dispose().catch(() => {}) From 2814a338bebf150d68bed6b315279ac51efb8a5b Mon Sep 17 00:00:00 2001 From: fz Date: Wed, 19 Aug 2026 10:01:11 +0800 Subject: [PATCH 103/137] test(subprocess): restore host-exit readiness handshake --- .../subprocess-local/tests/fixtures/process-exit-host.ts | 2 ++ .../subprocess/subprocess-local/tests/process-exit.spec.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts index 721c5134c3..e59289be09 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -13,6 +13,7 @@ if ((kind !== 'ordinary' && kind !== 'terminal') } const treeState = join(root, 'tree.json') +const ready = join(root, 'ready') const proceed = join(root, 'proceed') const managedTree = fileURLToPath(new URL('./managed-tree.ts', import.meta.url)) @@ -57,6 +58,7 @@ const published = JSON.parse(await readFile(treeState, 'utf8')) as { root?: unkn if (!Number.isSafeInteger(published.root) || !Number.isSafeInteger(published.descendant)) { throw new Error('managed tree published invalid process ids') } +await writeFile(ready, 'ready') await waitForFile(proceed) if (trigger === 'dispose') { diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index cdfc4f4ae0..217338fa1e 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -107,6 +107,10 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { let treeGone = false try { state = await readTree(join(root, 'tree.json')) + await vi.waitFor(() => readFile(join(root, 'ready'), 'utf8'), { + interval: 10, + timeout: scenarioTimeoutMs, + }) if (process.platform !== 'win32') identities = await captureIdentities(createProcessInspector(), state) await writeFile(join(root, 'proceed'), 'proceed') const outcome = await child From f97ea54ca95d3f6654f82039fab85bb7ff37d903 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 18 Aug 2026 11:01:58 +0800 Subject: [PATCH 104/137] fix(ci): install Wine offline from the restored apt archive The windows wine-blocking job installs Wine with apt-get over the local .deb archive, but apt re-downloads the full 100+ MB closure from the Ubuntu mirror anyway. A degraded runner network stalled that transfer past the job's 15-minute budget and cancelled the check. Install the restored archive directly with dpkg (no repository access) and keep the apt network install as the fallback when the archive cannot satisfy the closure. --- .github/workflows/ci.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cab0cf791..7cdfda7f87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -389,7 +389,17 @@ jobs: - name: Install Wine run: | if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then - sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + # The restored archive is the full --download-only closure of + # `wine` for this runner image, so installing the .debs directly + # with dpkg needs no repository access. apt-get would instead + # re-download the same 100+ MB closure from the mirror, which has + # stalled the job past its budget on a degraded runner network. + # If the archive cannot satisfy the closure, fall back to the apt + # network install. + if ! sudo DEBIAN_FRONTEND=noninteractive dpkg -i "$HOME"/wine-debs/*.deb; then + sudo DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true + sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb + fi else sudo apt-get update sudo apt-get install -y --no-install-recommends --download-only wine From 7078918b30ebf8c18430fde7eeaee8da5a1721e5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 19 Aug 2026 11:39:41 +0800 Subject: [PATCH 105/137] feat(llm-deepseek): support multimodal requests --- .agents/notes/archived/manifest.json | 3 + ...6-07-04-drop-image-content-block.i18n.yaml | 4 +- .../2026-07-04-drop-image-content-block.md | 1 + .../2026-07-04-drop-image-content-block.zh.md | 1 + ...6-06-11-content-block-vocabulary.i18n.yaml | 4 +- .../2026-06-11-content-block-vocabulary.md | 2 +- .../2026-06-11-content-block-vocabulary.zh.md | 2 +- ...8-18-request-image-payload-bound.i18n.yaml | 4 +- .../2026-08-18-request-image-payload-bound.md | 3 +- ...26-08-18-request-image-payload-bound.zh.md | 3 +- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 8 +- ...-image-input-and-durable-attachments.zh.md | 8 +- ...-19-direct-deepseek-vision-input.i18n.yaml | 6 + ...2026-08-19-direct-deepseek-vision-input.md | 34 +++ ...6-08-19-direct-deepseek-vision-input.zh.md | 34 +++ .../tests/onboarding-deepseek-config.e2e.ts | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- .../code-mode-image.cordis.snapshot.yml | 6 +- examples/acp-agent/image.cordis.snapshot.yml | 12 +- examples/acp-agent/image.cordis.yml | 3 +- examples/acp-agent/tests/acp.snapshot.ts | 100 +++++-- .../tests/fixtures/image-offload.cordis.yml | 33 +-- .../code-mode-read-image/session.jsonl | 8 +- .../system-prompt.expected.md | 2 +- .../inline-image-prompt/session.jsonl | 6 +- .../read-image-dimension/session.jsonl | 8 +- .../tests/snapshots/read-image/session.jsonl | 8 +- .../read-image/system-prompt.expected.md | 24 ++ .../src/client/ModelSelect.module.css | 31 ++- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 25 +- packages/llm/llm-deepseek/README.zh.md | 25 +- packages/llm/llm-deepseek/package.json | 2 + packages/llm/llm-deepseek/src/adapter.ts | 52 +++- packages/llm/llm-deepseek/src/index.ts | 35 ++- packages/llm/llm-deepseek/src/serialize.ts | 243 +++++++++++++++--- packages/llm/llm-deepseek/src/types.ts | 19 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 61 +++++ .../llm/llm-deepseek/tests/adapter.spec.ts | 141 +++++++++- .../llm-deepseek/tests/dynamic-config.spec.ts | 76 +++++- .../llm/llm-deepseek/tests/serialize.spec.ts | 184 ++++++++++++- packages/llm/llm-pi-ai/src/context.ts | 90 +------ packages/llm/llm-pi-ai/tests/context.spec.ts | 4 +- packages/llm/llm/src/content.ts | 78 ++++++ packages/llm/llm/tests/content.spec.ts | 76 ++++++ pnpm-lock.yaml | 3 + 49 files changed, 1251 insertions(+), 251 deletions(-) rename .agents/notes/{implemented => archived}/simplification/2026-07-04-drop-image-content-block.i18n.yaml (69%) rename .agents/notes/{implemented => archived}/simplification/2026-07-04-drop-image-content-block.md (99%) rename .agents/notes/{implemented => archived}/simplification/2026-07-04-drop-image-content-block.zh.md (99%) create mode 100644 .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md create mode 100644 .agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md create mode 100644 examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md create mode 100644 packages/llm/llm/tests/content.spec.ts diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index f8786470c7..2c9391bc96 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -334,6 +334,9 @@ "simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml": "sha256:eef600eafd70a576b2ac16a74f5dd5010ee601376008a90be10f1da56d746cee", "simplification/2026-07-02-remove-stream-chunk-mirror.md": "sha256:0c22a896260c6eb8991cc8babd8172f7b6889fc41bb891d748f34650b3eee5ec", "simplification/2026-07-02-remove-stream-chunk-mirror.zh.md": "sha256:ae7a3c2450b16fdf2f8da9e1f83cd987bf387671ecc8ebb76d2e7541695e7ee1", + "simplification/2026-07-04-drop-image-content-block.i18n.yaml": "sha256:6af8bc85be1fc23b445b17ff96df69488afa82c7194fcb47beb7a6b7b5a1de6a", + "simplification/2026-07-04-drop-image-content-block.md": "sha256:31e619d09405044a4db4951084f8d12417868b8f255ca26a44af0a5a13d43e6b", + "simplification/2026-07-04-drop-image-content-block.zh.md": "sha256:8fa6558bbb014a38853796102099ffbd5d8151371d68d40b2bf262ad07f4c976", "simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml": "sha256:e4c992a27ae0e37e5ef663c2cddf55eefe20387fd6103bebf655834d8e75e9db", "simplification/2026-07-04-drop-inert-request-knobs.md": "sha256:8735c2b868a85b13235e0491a0fa7b9570dd090eef5170324fc5e93782687b67", "simplification/2026-07-04-drop-inert-request-knobs.zh.md": "sha256:78b243f5d580f2a6fbbdb7d26574295d6ed74feb8d9bba34bbcdf4aa87624b5c", diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml similarity index 69% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.i18n.yaml index f51aaf86b4..f718de7c8a 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.i18n.yaml +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.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-07-04-drop-image-content-block.md -2026-07-04-drop-image-content-block.md: 5d0357270973ac20234d8ce72c403f27ce2ad0e0 -2026-07-04-drop-image-content-block.zh.md: a13133059cdf4f6e6e8fe44e313a9795a57c10c9 +2026-07-04-drop-image-content-block.md: 362afbbe7c5c1a3b46b1e8abda3b0fec77d1a0e6 +2026-07-04-drop-image-content-block.zh.md: 07fbbd1f165887bf3305ccfa7ba137656ce0a99c diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md index 5d03572709..362afbbe7c 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.md @@ -1,6 +1,7 @@ # Agent Note: Drop the `image` content block until a path can honor it Status: implemented +Archived: 2026-08-19 English | [中文](2026-07-04-drop-image-content-block.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md rename to .agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md index a13133059c..07fbbd1f16 100644 --- a/.agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.zh.md +++ b/.agents/notes/archived/simplification/2026-07-04-drop-image-content-block.zh.md @@ -1,6 +1,7 @@ # Agent Note: 移除 `image` 内容块,直到有路径能真正处理它 Status: implemented +Archived: 2026-08-19 [English](2026-07-04-drop-image-content-block.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index 0b150d8090..0c0e629d26 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.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-11-content-block-vocabulary.md -2026-06-11-content-block-vocabulary.md: 5228724bb9101307db9929aaf7831b477c2a6022 -2026-06-11-content-block-vocabulary.zh.md: b43de335917c6b5304a94b296fcb8253827b0600 +2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b +2026-06-11-content-block-vocabulary.zh.md: 5ac882e9de7dea02cc6534aee99c46869cc9363f diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md index 5228724bb9..a31df6a7d1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -22,7 +22,7 @@ In-session context injection (`context/message`) and mid-turn steering originall ## Consequences - Reasoning has a core home without provider-specific shapes. -- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md). +- Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md). - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Notes. - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index b43de33591..5ac882e9de 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -22,7 +22,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 ## 后果 - 推理(reasoning)在核心层有了归属,无需依赖提供方特有的结构。 -- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../simplification/2026-07-04-drop-image-content-block.md)。 +- 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md)。 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 - 跨包边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml index f797c08086..892a732be0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.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-18-request-image-payload-bound.md -2026-08-18-request-image-payload-bound.md: df10df39d18c7da4660b566e8f0b6a5a60ff8dc1 -2026-08-18-request-image-payload-bound.zh.md: 070f2d194f1459f3f2728fdf9d5d2db2c3a24385 +2026-08-18-request-image-payload-bound.md: 0ec4594888db6157fb8cfd3e7bdb231b842d53c1 +2026-08-18-request-image-payload-bound.zh.md: 17b3fbb09be1aff0036c1cec389d8dd75966e010 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md index df10df39d1..0ec4594888 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.md @@ -10,7 +10,7 @@ Every image in session history is base64-inlined into every model request by the ## Decision -The pi-ai provider profile carries `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, per route, changeable from cordis.yml and the `llm-pi-ai` settings section). At request conversion, `toPiContext` sums the base64 length of every image in history (derived from `ImageAttachmentRef.bytes` without reading data) and, while the sum exceeds the bound, replaces the oldest images with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Offload locations use message and nested block indexes rather than object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. `classifyPiAiError` classifies 413 and specific request-body-cap wording as `INVALID_REQUEST` (resending the same body cannot succeed). Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB request-image default therefore retains four such images and reserves the rest of a 32MiB request for system prompts, history, tools, and JSON. Deployments behind stricter gateways lower the value per route. +The pi-ai provider profile and direct DeepSeek adapter carry `maxRequestImageBytes` (default `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`, a positive integer, changeable from cordis.yml and settings). The provider-neutral `offloadRequestImages` conversion sums the base64 length of every image in history from `ImageAttachmentRef.bytes` without reading data and, while the sum exceeds the bound, replaces the oldest image occurrences with a fixed model-facing placeholder. The placeholder tells the model to read the file again when a path is available or ask the user to attach the image again. The most recent images are omitted last; an image larger than the bound is itself omitted. Occurrence-order replacement does not depend on object identity, so replaying the same JSON log produces the same request. Offloaded images are never read from the attachment store. Both adapters classify 413 as `INVALID_REQUEST`; pi-ai also recognizes specific request-body-cap wording. Four images admitted at the attachment store's 3.5MiB raw-image default occupy at most 18.67MiB after base64 expansion. The 20MiB default therefore retains four such images and leaves headroom under the direct API's 30MiB request limit, while deployments behind stricter gateways lower the value per route. ## Offload is conversion, not history @@ -26,6 +26,7 @@ The placeholder is model-visible but not logged as a session event. It stays wit ## Related - [Per-side image dimension admission limit](2026-08-17-image-dimension-admission-limit.md) — the admission-layer companion fix; together they close the two observed session-poisoning failures (400 dimension, 413 body size). +- [Direct DeepSeek vision input](../feature/2026-08-19-direct-deepseek-vision-input.md) — applies this provider-neutral conversion to the official multimodal route. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md index 070f2d194f..17b3fbb09b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-request-image-payload-bound.zh.md @@ -10,7 +10,7 @@ pi-ai 适配器把会话历史中的每张图片 base64 内联进每一个模型 ## Decision -pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,按路由生效,可从 cordis.yml 与 `llm-pi-ai` settings 段修改)。请求转换时,`toPiContext` 由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。offload 位置用消息与嵌套块的索引表示,不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。`classifyPiAiError` 把 413 与明确的请求体上限措辞归类为 `INVALID_REQUEST`(原样重发不可能成功)。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 请求图片默认上限因此可保留四张这样的图片,并在 32MiB 请求内为系统提示词、历史、工具与 JSON 保留其余容量。网关更严格的部署按路由调低该值。 +pi-ai provider profile 与直接 DeepSeek 适配器都提供 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20MiB`,正整数,可从 cordis.yml 与 settings 修改)。提供方无关的 `offloadRequestImages` 转换由 `ImageAttachmentRef.bytes` 推算每张历史图片的 base64 长度(无需读取数据)求和,总和超过上限时从最老的图片出现位置开始替换为一段固定的模型可见占位文本。占位文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。越新的图片越晚被省略;单张图片本身超过上限时也会被省略。按出现顺序替换不依赖对象身份,因此重放同一份 JSON 日志会产生相同请求。被 offload 的图片不会从附件存储读取。两个适配器都把 413 归类为 `INVALID_REQUEST`;pi-ai 还会识别明确的请求体上限措辞。四张按附件存储默认上限准入的 3.5MiB 原始图片,经 base64 膨胀后最多占 18.67MiB。20MiB 默认上限因此可保留四张这样的图片,并在直接 API 的 30MiB 请求上限下留出余量;网关更严格的部署则按路由调低该值。 ## offload 是转换而非历史 @@ -26,6 +26,7 @@ pi-ai provider profile 增加 `maxRequestImageBytes`(默认 `DEFAULT_MAX_REQUE ## Related - [图片单边尺寸准入上限](2026-08-17-image-dimension-admission-limit.md),准入层的配套修复;两者合起来封住已观测到的两类会话毒化故障(400 尺寸、413 请求体)。 +- [直接 DeepSeek 视觉输入](../feature/2026-08-19-direct-deepseek-vision-input.md)把这项提供方无关转换应用于官方多模态路由。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 842f2c9cd3..73b81c1686 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 6a12380992c3205d6bb3f5701f093b3335fae2f9 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: a8e94154e435a5359e7928d3b11eba7d1aee92f7 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 83293fbe9bd4e8a7b4422dbbe8495cf9f48bcdb5 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 5c524da9cbed7b330c3823812c8a71aed7aa74b0 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 6a12380992..83293fbe9b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -8,7 +8,7 @@ English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationController.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history. -This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. +This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../archived/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. @@ -124,7 +124,7 @@ Model catalog entries gain optional merge-extensible input modality declarations The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection announces through the composer's transient toast. -The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. +Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, recursively convert each durable image reference including references nested inside tool results, and emit native image content only for models that declare image input. The direct route accepts configured image-capable catalog entries but does not advertise a vision model until its endpoint is ready; its Flash, Pro, custom models without an image declaration, and unlisted pass-through ids remain text-only. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. No adapter may flatten or skip a retained image; unsupported roles and models fail with typed `UNSUPPORTED_CONTENT`. Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. @@ -152,7 +152,7 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc | `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. | | `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. | | `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | -| `packages/llm/llm-deepseek` | Reject image content explicitly. | +| `packages/llm/llm-deepseek` | Resolve declared official vision input and reject images for text-only models. | | `packages/compaction/compaction-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | | `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, shared batch admission, limits and routed-model preflight, persist-before-event ordering, session-authorized reads, and default profile composition. | | `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | @@ -165,7 +165,7 @@ The attachment packages form the interface/implementation side of one capability ### Implementation -The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. +The implemented slice includes the attachment seam and shared batch admission, role-neutral image block, Pi-AI and direct DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image wire support, lossless MCP canonical results with durable image projection, generic Code Mode rich-result forwarding, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, single-click preview, compaction handling, and keyless assembled Web and ACP coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index a8e94154e4..5c524da9cb 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -8,7 +8,7 @@ Status: implemented 在此变更之前,Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationController.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 -这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 +这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../archived/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 [Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 @@ -124,7 +124,7 @@ Base64 只跨越一次协议边界,并在持久化后丢弃。每个入口都 宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 的短时 toast 播报。 -Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 +Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。直接路由接受已配置且支持图片的 catalog 配置项,但在视觉模型端点就绪前不会公布视觉模型;其 Flash、Pro、未声明图片能力的自定义模型和未列出原样传递 id 仍仅支持文本。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。任何适配器都不得将保留的图片展平或跳过;不支持的角色与模型会以类型化的 `UNSUPPORTED_CONTENT` 失败。 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 @@ -152,7 +152,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme | `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 | | `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | | `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | -| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | +| `packages/llm/llm-deepseek` | 解析已声明的官方视觉输入,并拒绝纯文本模型的图片。 | | `packages/compaction/compaction-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | | `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、共享批量准入、限制和路由模型前置检查、先持久化再追加事件的顺序、会话授权读取,以及默认 profile 组合。 | | `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | @@ -165,7 +165,7 @@ Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachme ### 实现 -已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 +已实现的范围包括附件服务边界与共享批量准入、角色无关的图片块、Pi-AI 与直接 DeepSeek 输入转换、Web/ACP/MCP 的持久化顺序、Web 上传与读取协议、条件式 ACP 图片协议支持、无损 MCP 规范结果与持久图片投影、通用 Code Mode 丰富结果转发、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、单击预览、压缩处理,以及组装后无需密钥的 Web 与 ACP 覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml new file mode 100644 index 0000000000..a2caf36d87 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.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-19-direct-deepseek-vision-input.md +2026-08-19-direct-deepseek-vision-input.md: 60f55f10e108a29d465e388fa385b1955b7f2ce5 +2026-08-19-direct-deepseek-vision-input.zh.md: 7f41d89ffd13da1b1b170f114dfc481e66075ef2 diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md new file mode 100644 index 0000000000..60f55f10e1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md @@ -0,0 +1,34 @@ +# Agent Note: Direct DeepSeek vision input + +Status: implemented + +English | [中文](2026-08-19-direct-deepseek-vision-input.zh.md) + +## Problem + +DeepSeek vision deployments use the chat-completions image protocol, but the direct `deepseek-official` adapter declares every catalog and pass-through model text-only and rejects every `ImageBlock`. The durable attachment path therefore works only through configurable pi-ai routes, and a deployment cannot pass user uploads or image-bearing tool results through the direct provider. + +## Decision + +The direct adapter lets a configured model opt in with `inputModalities: [text, image]`; validation rejects empty, unknown, or duplicate modalities. Flash, Pro, unlisted ids, and configured models that omit `inputModalities` remain explicitly text-only. The shipped catalog does not advertise `deepseek-v4-flash-vision-exp` until its model endpoint is ready, so the model selector cannot offer an unavailable route; deployment and snapshot catalogs can enable their exact vision model independently. + +The adapter resolves `ctx.attachments` per image request, reads each retained durable reference with the request signal, and serializes verified bytes as ordered OpenAI-compatible `image_url` data URLs. Text-only user messages retain string content. Tool results retain string-only `tool` messages; image-only results use `(see attached image)`, and consecutive retained tool-result images follow in one `user` message beginning `Attached image(s) from tool result:`. System and assistant history images fail with `UNSUPPORTED_CONTENT` before attachment or network I/O. + +The direct adapter and pi-ai conversion share the deterministic [request-level image payload bound](../bug-fix/2026-08-18-request-image-payload-bound.md). Both default to 20 MiB of accumulated base64 payload, replace oldest image occurrences with the same fixed placeholder, and never read omitted attachments. Direct HTTP 413 responses are `INVALID_REQUEST`; attachment failures retain their stable attachment code rather than becoming `TRANSPORT`. + +Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist only while preparing one provider request, so no session event, persistence format, API schema, or SDK projection changes. The route accepts PNG, JPEG, WebP, and GIF already admitted by the attachment service. External image URLs, the Files API, and image output remain unsupported. + +## Alternatives considered + +- **Use only the pi-ai DeepSeek provider.** Its generic multimodal path proves the content conversion, but it does not make the direct official route truthful or usable with the official model id. +- **Declare the whole provider image-capable.** This would let Flash, Pro, and unknown pass-through ids accept durable images that their exact wire model cannot promise to consume. Capability remains exact-model metadata. +- **Send images inside `tool` message content.** The documented compatible form keeps tool content a string. A following user message avoids relying on an undocumented multimodal tool-role form while preserving call-result order. +- **Add external URLs or Files uploads.** Both require new canonical input, authorization, lifetime, cleanup, and replay decisions. Transient base64 uses the existing durable attachment contract without expanding those concerns. + +## Verification + +Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. A key-gated real-API e2e sends a deterministic image to the official vision model. + +## Consequences + +Configured DeepSeek vision routes can consume durable user and tool-result images without changing session durability or response streaming. Repeated history still expands request bodies, but deterministic oldest-first offload bounds the dominant payload and leaves headroom below the official 30 MiB request-body limit. Image token pricing remains provider-owned because the official image token formula is not available. diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md new file mode 100644 index 0000000000..7f41d89ffd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 直接 DeepSeek 视觉输入 + +Status: implemented + +[English](2026-08-19-direct-deepseek-vision-input.md) | 中文 + +## Problem + +DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek-official` 适配器把所有 catalog 与原样传递模型都声明为仅文本,并拒绝每一个 `ImageBlock`。因此,持久附件路径只能经可配置 pi-ai 路由工作,部署方无法通过直接提供方传递用户上传或包含图片的工具结果。 + +## Decision + +直接适配器允许已配置模型通过 `inputModalities: [text, image]` 选择加入;校验会拒绝空列表、未知模态或重复模态。Flash、Pro、未列出 id,以及省略 `inputModalities` 的已配置模型仍明确仅支持文本。在模型端点就绪前,随附目录不会公布 `deepseek-v4-flash-vision-exp`,因此模型选择器不会提供不可用路由;部署与 snapshot 目录可以独立启用其确切视觉模型。 + +适配器会对每个图片请求解析 `ctx.attachments`,用请求 signal 读取每个保留的持久引用,并将校验后的字节按顺序序列化为 OpenAI 兼容的 `image_url` data URL。纯文本 user 消息保留字符串内容。工具结果保留仅字符串的 `tool` 消息;仅含图片的结果使用 `(see attached image)`,连续工具结果中保留的图片随后合并进一条以 `Attached image(s) from tool result:` 开头的 `user` 消息。System 与 assistant 历史图片会在附件或网络 I/O 前以 `UNSUPPORTED_CONTENT` 失败。 + +直接适配器与 pi-ai 转换共享确定性的[请求级图片载荷上限](../bug-fix/2026-08-18-request-image-payload-bound.md)。两者都以 20 MiB 累计 base64 payload 为默认值,用相同固定占位文本替换最旧的图片出现位置,并且绝不读取被省略的附件。直接 HTTP 413 响应归类为 `INVALID_REQUEST`;附件失败会保留其稳定附件 code,不会变成 `TRANSPORT`。 + +规范消息继续只存储 `ImageAttachmentRef`。Data URL 只在准备单次提供方请求时存在,因此无需修改会话事件、持久化格式、API schema 或 SDK 投影。路由接受已经由附件服务准入的 PNG、JPEG、WebP 和 GIF。不支持外部图片 URL、Files API 和图片输出。 + +## Alternatives considered + +- **只使用 pi-ai DeepSeek 提供方。** 其通用多模态路径验证了内容转换,但无法让直接官方路由如实公布能力,也无法让它配合官方模型 id 使用。 +- **把整个提供方声明为支持图片。** 这样会让 Flash、Pro 和未知的原样传递 id 接受持久图片,但其确切协议模型无法承诺消费这些图片。能力仍属于确切模型元数据。 +- **在 `tool` 消息内容中发送图片。** 已记录的兼容形式要求工具内容保持字符串。随后发送 user 消息可避免依赖未记录的多模态 tool role 形式,同时保留调用结果顺序。 +- **增加外部 URL 或 Files 上传。** 两者都需要新的规范输入、授权、生命周期、清理和重放决策。瞬态 base64 可以复用现有持久附件约定,不扩展这些问题。 + +## Verification + +包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。受密钥控制的真实 API e2e 会向官方视觉模型发送一张确定性图片。 + +## Consequences + +已配置的 DeepSeek 视觉路由可以消费持久 user 与工具结果图片,而无需改变会话持久性或响应流。重复历史仍会扩张请求正文,但确定性的最旧优先 offload 会限制主导 payload,并在官方 30 MiB 请求正文上限下保留余量。由于官方图片 token 公式尚不可用,图片 token 定价仍由提供方掌握。 diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index ad9bc0903f..407a53dd22 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -224,7 +224,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup expect(document).toContain('name: Private Preview') expect(document).toContain('contextWindow: 131072') expect(document).toContain('maxTokens: 64000') - expect(document).not.toContain('id: deepseek-v4-flash') + expect(document).not.toMatch(/^\s*- id: deepseek-v4-flash$/m) await page.keyboard.press('Escape') // A connected Workspace is what puts a live composer — and its model diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 6f7ac3d6b3..4c30b3c12a 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: c03f1088745a5746a59b2fbddf60d7b270951371 -config-catalog.zh.md: 4c1f62f7c899dd6bf7c55c33e802859be9e79e5e +config-catalog.md: a8b85928addc86bcebbee1be4202abfffd3f5be5 +config-catalog.zh.md: 53034a3f6e4bdad0284b892e8a1031f43df469cf diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c03f108874..a8b85928ad 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -870,6 +870,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -886,12 +888,14 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: 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/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 4c1f62f7c8..53034a3f6e 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -872,6 +872,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -888,12 +890,14 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: ModelModality[] } ``` -依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:72`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/examples/acp-agent/code-mode-image.cordis.snapshot.yml b/examples/acp-agent/code-mode-image.cordis.snapshot.yml index f722a66efd..4f227ec19a 100644 --- a/examples/acp-agent/code-mode-image.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode-image.cordis.snapshot.yml @@ -13,7 +13,7 @@ name: '@deepseek-ai/dsh-acp-demo' config: provider: deepseek-official - model: deepseek-v4-flash + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -37,6 +37,8 @@ name: DeepSeek models: - id: deepseek-v4-flash - inputModalities: [text, image] + inputModalities: [text] - id: deepseek-v4-pro inputModalities: [text] + - id: deepseek-v4-flash-vision-exp + inputModalities: [text, image] diff --git a/examples/acp-agent/image.cordis.snapshot.yml b/examples/acp-agent/image.cordis.snapshot.yml index f056f775d3..7a355618d3 100644 --- a/examples/acp-agent/image.cordis.snapshot.yml +++ b/examples/acp-agent/image.cordis.snapshot.yml @@ -1,9 +1,9 @@ # 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. +# re-pins the recorded vision model. The replay catalog declares image input, +# so the strict read_image gate accepts the route and the tool result carries +# the durable image block. - id: base name: '@deepseek-ai/cordis-plugin-include' config: @@ -16,7 +16,7 @@ name: '@deepseek-ai/dsh-acp-demo' config: provider: deepseek-official - model: deepseek-v4-flash + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -36,6 +36,8 @@ name: DeepSeek models: - id: deepseek-v4-flash - inputModalities: [text, image] + inputModalities: [text] - id: deepseek-v4-pro inputModalities: [text] + - id: deepseek-v4-flash-vision-exp + inputModalities: [text, image] diff --git a/examples/acp-agent/image.cordis.yml b/examples/acp-agent/image.cordis.yml index a12cef1e85..3d60d107b4 100644 --- a/examples/acp-agent/image.cordis.yml +++ b/examples/acp-agent/image.cordis.yml @@ -3,7 +3,8 @@ # 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. +# patch replaces the whole app config. Keyless replay supplies its own exact +# image-capable catalog while the production endpoint remains unavailable. - id: base name: '@deepseek-ai/cordis-plugin-include' config: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 4742906b8a..d32ef2abeb 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -16,6 +16,7 @@ import { } from '@deepseek-ai/dsh-acp-snapshot' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' +import { OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' /** * The acp-agent example's snapshot suite: the scenario table for @@ -222,27 +223,26 @@ const SCENARIOS: Scenario[] = [ posixOnly: true, }, // Authored keyless replays through the assembled app: the replay catalog - // declares flash image-capable (success) or text-only (refusal), and the + // declares the vision model image-capable and Flash text-only, 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. + // attachment store. The success route selects the vision model while the + // refusal route retains text-only Flash, so each pins its exact header. { 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', + pinsHeader: true, + headerClass: 'image-text-route', + systemPromptSource: 'text-turn', + toolSchemasSource: 'read-image', configPath: IMAGE_TEXT_ROUTE_CONFIG, }, // Authored keyless replay of the oversized-image refusal: admission rejects @@ -688,7 +688,7 @@ defineAcpSnapshotSuite({ hasPwsh, }) -it('pins pi-ai image offload in the request sent by the assembled app', async () => { +it('pins native DeepSeek image offload in the request sent by the assembled app', async () => { const requests: Record[] = [] const server = createServer((request: IncomingMessage, response: ServerResponse) => { let body = '' @@ -697,13 +697,21 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () request.on('end', () => { requests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end([ - 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + const events = requests.length === 1 + ? [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"native-read-image","type":"function","function":{"name":"read_image","arguments":"{\\"file_path\\":\\"red.png\\"}"}}]},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ] + : [ + 'data: {"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{"content":"DONE"},"index":0,"finish_reason":null}]}', + 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ] + response.end(events.join('\n\n')) }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -722,7 +730,7 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () { type: 'image', data: image, mimeType: 'image/png' }, { type: 'text', text: ' with the newer image ' }, { type: 'image', data: image, mimeType: 'image/png' }, - { type: 'text', text: ', then reply with DONE.' }, + { type: 'text', text: ', then use read_image on red.png and reply with DONE.' }, ], }, ], @@ -734,13 +742,14 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () mode: 'record', configPath: IMAGE_OFFLOAD_CONFIG, fixtureFile: join(SNAPSHOTS_DIR, 'image-offload-request', 'session.jsonl'), + workspaceDir: join(SNAPSHOTS_DIR, 'read-image', 'workspace'), env: { DSH_SNAPSHOT_API_KEY: 'snapshot-key', - DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}/v1`, + DSH_SNAPSHOT_BASE_URL: `http://127.0.0.1:${address.port}`, }, }) expect(result.stderr).toBe('') - expect(requests).toHaveLength(1) + expect(requests).toHaveLength(2) const messages = requests[0]?.messages as { content?: unknown }[] | undefined const offloaded = messages?.find(message => JSON.stringify(message.content).includes('[image omitted')) expect(offloaded?.content).toMatchInlineSnapshot(` @@ -764,11 +773,62 @@ it('pins pi-ai image offload in the request sent by the assembled app', async () "type": "image_url", }, { - "text": ", then reply with DONE.", + "text": ", then use read_image on red.png and reply with DONE.", "type": "text", }, ] `) + + const followup = structuredClone((requests[1]?.messages as unknown[]).slice(1)) as Array<{ + role?: unknown + content?: unknown + }> + const toolMessage = followup.find(message => message.role === 'tool') + if (toolMessage === undefined || typeof toolMessage.content !== 'string') { + throw new Error('native read_image request has no tool content') + } + const cwdSpellings = [...new Set([result.cwd, ...result.cwdAliases].flatMap(cwd => ( + cwd.startsWith('/private/') ? [cwd, cwd.slice('/private'.length)] : [cwd, `/private${cwd}`] + )))] + let toolContent = toolMessage.content + for (const cwd of cwdSpellings) toolContent = toolContent.replaceAll(cwd, '{{cwd}}') + toolMessage.content = toolContent + expect(followup).toEqual([ + { + role: 'user', + content: `Compare the older image ${OFFLOADED_IMAGE_TEXT} with the newer image ${OFFLOADED_IMAGE_TEXT}, then use read_image on red.png and reply with DONE.`, + }, + { + role: 'user', + content: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\n' + + 'Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\n' + + '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: 'assistant', + content: '', + tool_calls: [{ + id: 'native-read-image', + type: 'function', + function: { name: 'read_image', arguments: '{"file_path":"red.png"}' }, + }], + }, + { + role: 'tool', + tool_call_id: 'native-read-image', + content: '{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n', + }, + { + role: 'user', + content: [ + { type: 'text', text: 'Attached image(s) from tool result:' }, + { + type: 'image_url', + image_url: { url: `data:image/png;base64,${image}` }, + }, + ], + }, + ]) } finally { await new Promise(resolve => server.close(() => { resolve() })) } diff --git a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml index 6007c5d44d..530f7b9663 100644 --- a/examples/acp-agent/tests/fixtures/image-offload.cordis.yml +++ b/examples/acp-agent/tests/fixtures/image-offload.cordis.yml @@ -1,5 +1,5 @@ -# Keyless assembled-request snapshot for pi-ai image offload. The local -# provider endpoint is supplied by the snapshot test; the real attachment +# Keyless assembled-request snapshot for native DeepSeek image offload. The +# local provider endpoint is supplied by the snapshot test; the real attachment # store and ACP bridge carry two uploaded images into one model request. - id: base name: '@deepseek-ai/cordis-plugin-include' @@ -8,12 +8,21 @@ patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true + config: + apiKeyEnv: DSH_SNAPSHOT_API_KEY + baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL + thinking: disabled + maxRequestImageBytes: 92 + models: + - id: deepseek-v4-flash-vision-exp + contextWindow: 32768 + maxTokens: 1024 + inputModalities: [text, image] - id: acp-agent name: '@deepseek-ai/dsh-acp-demo' config: - provider: snapshot - model: vision + provider: deepseek-official + model: deepseek-v4-flash-vision-exp persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' persistenceCompression: none workspaceContext: @@ -25,17 +34,3 @@ - insert: - id: attachment-local name: '@deepseek-ai/dsh-attachment-local' - - id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - snapshot: - apiKeyEnv: DSH_SNAPSHOT_API_KEY - api: openai-completions - baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL - maxRequestImageBytes: 92 - models: - - id: vision - contextWindow: 32768 - maxTokens: 1024 - input: [text, image] diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl b/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl index cb63f29c34..e8990b8f69 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1786431644558,"data":{"content":[{"type":"text","text":"Using ONE run_code program, create a one-pixel PNG with Node.js, call read_image on it, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"08e67dbb-9432-4fe4-b7da-4483998c0a31"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1786431644558,"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":"99b9db8d-e4ec-4ea9-b5e2-1e4c0ff6354b"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1786431644558,"data":{"title":"Using ONE run_code program, create","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1786431644559,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786431644560,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":10,"time":1786431644571,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1786431644572,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"644382c5-5a05-4bda-b8dc-b9195d6a7d8b"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1786431644572,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"ef352c42-b661-4b71-8c6a-7dbbd0a9f591"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1786431644573,"data":{"turn":1,"step":1,"callId":"code-image-call","name":"run_code","arguments":"{\"code\":\"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\\nawait tools.bash({ command: \\\"node -e \\\\\\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\\\\\"\\\", description: \\\"Create a one pixel PNG\\\" });\\nconst image = await tools.read_image({ file_path: \\\"red.png\\\" });\\nreturn image.path;\",\"description\":\"Create and inspect one image\"}"}} {"type":"tool/code-dispatch-start","seq":15,"time":1786431644697,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"}}} {"type":"tool/code-dispatch","seq":16,"time":1786431644828,"data":{"rootCallId":"code-image-call","parentCallId":"code-image-call","subCallId":"code-image-call:code:1","name":"bash","arguments":{"command":"node -e \"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\"","description":"Create a one pixel PNG"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}} @@ -28,6 +28,6 @@ {"type":"assistant/chunk","seq":26,"time":1786431644889,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":27,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":28,"time":1786431644890,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a6da60ea-d420-432b-ba00-9b99af045110"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"} +{"type":"assistant/message","seq":29,"time":1786431644890,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a721cef2-2c49-4336-8d07-5f6cc15f4b67"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28],"surfaceOp":"append"} {"type":"step/end","seq":30,"time":1786431644890,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":31,"time":1786431644890,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md index e96ddb107c..786aa8fe80 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/system-prompt.expected.md @@ -1,6 +1,6 @@ You are an AI agent powered by DeepSeek Harness. -You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. +You are a coding assistant powered by the deepseek-v4-flash-vision-exp model. Your working directory is {{cwd}}. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl b/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl index 89cffe656d..08d65c2c91 100644 --- a/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl +++ b/examples/acp-agent/tests/snapshots/inline-image-prompt/session.jsonl @@ -6,12 +6,12 @@ {"type":"user/message","seq":4,"time":1783952000003,"data":{"content":[{"type":"text","text":"Inspect this image, then reply with exactly "},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}},{"type":"text","text":"the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0c0c0c0c-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1783952000004,"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":"0c0c0c0c-0000-4000-8000-000000000002"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1783952000004,"data":{"title":"Inspect this image, then reply","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1783952000005,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783952000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"type":"assistant/chunk","seq":9,"time":1783952000006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":10,"time":1783952000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} {"type":"assistant/chunk","seq":11,"time":1783952000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1783952000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0c0c0c0c-0000-4000-8000-000000000003"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1783952000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"e58e49ab-9c34-4ba0-9276-9429b32c5ea0"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"step/end","seq":14,"time":1783952000010,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":15,"time":1783952000010,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl index 0b979e801f..7c74dd2d71 100644 --- a/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/session.jsonl @@ -6,13 +6,13 @@ {"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on wide.png in the current directory. If the tool refuses because the image is too large, reply with exactly the single word TOOLARGE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} {"type":"user/message","seq":5,"time":1783951000004,"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":1783951000004,"data":{"title":"Use read_image on wide.png in","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"request/header","seq":7,"time":1783951000004,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1783951000005,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"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":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1783951000008,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.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":"assistant/message","seq":13,"time":1783951000009,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"a25d70ac-2bd6-4e44-9121-ed74975ee229"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1783951000009,"data":{"turn":1,"step":1,"callId":"read-image-dimension","name":"read_image","arguments":"{\"file_path\":\"wide.png\"}"}} {"type":"tool/result","seq":15,"time":1783951000014,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-dimension"},"content":[{"type":"tool-result","toolCallId":"read-image-dimension","content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/wide.png\": at least one image side exceeds the 2000px limit; downscale the image and read the smaller copy"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1783951000014,"data":{"turn":1,"step":1}} @@ -21,6 +21,6 @@ {"type":"assistant/chunk","seq":19,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"TOOLARGE"}}}} {"type":"assistant/chunk","seq":20,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":21,"time":1783951000017,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"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":"assistant/message","seq":22,"time":1783951000018,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"TOOLARGE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"id":"3a95dd83-34f7-4bc0-afb6-7ba3c9b483be"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1783951000018,"data":{"turn":1,"step":2}} {"type":"turn/end","seq":24,"time":1783951000018,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image/session.jsonl b/examples/acp-agent/tests/snapshots/read-image/session.jsonl index 6acde5a0ff..72064a302d 100644 --- a/examples/acp-agent/tests/snapshots/read-image/session.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image/session.jsonl @@ -6,13 +6,13 @@ {"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":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash-vision-exp"}} {"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":"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-vision-exp"},"id":"2b71c837-237d-4d92-a857-8b8ad1a3f237"},"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}} @@ -21,6 +21,6 @@ {"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":"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-vision-exp"},"id":"5a45946c-b9f4-4f2c-a7c3-2569e541ec1d"},"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/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md new file mode 100644 index 0000000000..2d4ef255b8 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/system-prompt.expected.md @@ -0,0 +1,24 @@ +You are an AI agent powered by DeepSeek Harness. + +You are a coding assistant powered by the deepseek-v4-flash-vision-exp 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. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. + +Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index b19242fb44..76b9d3cb2a 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -11,7 +11,10 @@ align-items: center; gap: 4px; min-width: 0; + /* Legacy fallback, then a row-relative cap that grants long names more + room without letting this trailing control consume the composer. */ max-width: 220px; + max-width: min(360px, 45cqw); height: 28px; padding: 0 4px 0 8px; border: none; @@ -69,7 +72,11 @@ z-index: 20; display: flex; flex-direction: column; - width: min(240px, calc(100vw - 32px)); + /* Let the visible pane's rows set the card width, but keep pathological ids + and narrow screens inside a bounded card where ellipsis remains. */ + width: max-content; + min-width: min(240px, calc(100vw - 32px)); + max-width: min(420px, calc(100vw - 32px)); max-height: min(360px, calc(100vh - 96px)); overflow: hidden; padding: 4px; @@ -150,10 +157,12 @@ } .option { + box-sizing: border-box; display: flex; align-items: center; gap: 8px; - width: 100%; + width: auto; + min-width: 100%; min-height: 38px; padding: 6px 8px; border: none; @@ -218,10 +227,12 @@ padding, 8px gap, 10px radius; 14/22 label in primary, value in the #81858C tertiary tone, right chevron drilling into the sub-list. */ .cell { + box-sizing: border-box; display: flex; align-items: center; gap: 8px; - width: 100%; + width: auto; + min-width: 100%; height: 40px; padding: 0 10px; border: none; @@ -239,19 +250,17 @@ } .cellLabel { + flex: 0 0 auto; + white-space: nowrap; +} + +.cellValue { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} - -.cellValue { - flex: 0 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + text-align: right; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index cf54a96ec7..a049b4dd87 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 9bb28e6876b82c521341769123a8b2d0e5d98e09 -README.zh.md: 21c55cfa32bf68e0cac4c0bd72c94c86d955fc00 +README.md: 599dc1f530884df58fcc64d8cdf6c62fac80bc1f +README.zh.md: b1a45a1f398fdbb11cddebe34b646edf37f579be diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 9bb28e6876..599dc1f530 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -20,6 +20,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + maxRequestImageBytes: 20971520 # optional positive integer; 20 MiB base64-payload default retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: @@ -30,12 +31,19 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek-V4-Flash + - id: private-vision + name: Private Vision + inputModalities: [text, image] - id: private-reasoner description: Company-hosted reasoning model contextWindow: 512000 ``` -The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` as `DeepSeek-V4-Flash` and `deepseek-v4-pro` as `DeepSeek-V4-Pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. +The plugin registers the single provider route `deepseek-official` together with its resolved `retryPolicy`; omission resolves to normal mode with five retries. A request selects it with `provider: deepseek-official`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 1,000,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Vision models are not advertised by default until their endpoint rollout is complete, but a deployment can add one with `inputModalities: [text, image]`. Catalog entries are exposed through `ctx.llm.listModels('deepseek-official')` for clients such as ACP editors and the Web selector, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id, and omitted `inputModalities` means `text` only. + +An image-capable catalog entry may declare `inputModalities: [text, image]`. The adapter resolves user and tool-result `ImageBlock` references through `ctx.attachments`, verifies the stored bytes, and sends transient `data:;base64,...` `image_url` parts without changing the durable session message. Text-only and unlisted models reject image input before credential, attachment, or network I/O. System and assistant history remain image-free; tool-result images follow their string-only `tool` messages in a separate `user` message. + +`maxRequestImageBytes` bounds accumulated base64 image payload and defaults to 20 MiB, leaving headroom below the official 30 MiB request-body limit for text, tools, and JSON framing. When history exceeds the bound, the oldest images become the fixed model-visible placeholder `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]` until the request fits; omitted attachments are not read. Attachment admission continues to own per-image and per-message raw-byte, media, dimension, and pixel limits. `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelInfo('deepseek-official', model).context` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. The adapter default is 1,000,000; pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek-official` throws `LlmError('DUPLICATE_ADAPTER')`. @@ -49,10 +57,11 @@ The same exact-model result exposes ordered `off`, `low`, `high`, and `max` effo ## Dynamic configuration (settings + credentials) -Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: +Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, image bound, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Three optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. - **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint. Configuration carries only `apiKeyEnv`, never a literal key: the reference resolves through the credential seam, and without a mounted seam through the trusted environment layers. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. Every resolved key is format-checked before use, so a value no HTTP header can carry is refused with `LlmError('INVALID_CREDENTIAL')` naming the failing entry point — never any part of the key — instead of surfacing as an opaque `fetch` `TypeError`. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.attachments`** — image requests resolve this service at request time, so Cordis load order does not freeze optional image availability. Absence rejects image input with `UNSUPPORTED_CONTENT`; text-only calls do not require the service. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. @@ -70,11 +79,12 @@ DeepSeek request identity is separate from app attribution. After credential res - The adapter-owned `off` effort maps to `thinking: {type: 'disabled'}` and never crosses the wire as `reasoning_effort: 'off'`. - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block). - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens). +- Image-capable user messages preserve text/image order. Tool-role content remains a string; consecutive tool-result images are grouped into the following user message with `Attached image(s) from tool result:`. - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. ## Errors -Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). +Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s and 413), `SERVER` (5xx), `HTTP_` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. Attachment reads retain their stable attachment failure code rather than becoming transport failures. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy). ## Model Experience @@ -82,15 +92,15 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config without adapter-authored prompt prose. The vision model also receives retained user and tool-result images as base64 data URLs; an over-budget older image is represented by the documented placeholder. On a prior assistant turn with tool calls, its reasoning content is passed back as required; reasoning from tool-call-free turns is omitted. #### Token effect -Provider tokenization governs exact input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning avoids paying those tokens again; cache-read usage is reported when available. +Provider tokenization governs exact text and image-token input. Conditional reasoning passback increases tool-round-trip context, while dropping other reasoning and over-budget images avoids paying those tokens again; cache-read usage is reported when available. #### KV Cache effect -An unchanged assembled prefix is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, or history change may prevent reuse from the first changed token; reasoning passback appends during tool round trips. +An unchanged assembled prefix, including deterministically encoded retained images and placeholders, is eligible for DeepSeek cache reuse, which this adapter reports in usage. A model-route change or any upstream prompt, schema, prefix, history, or image-budget change may prevent reuse from the first changed token; reasoning passback appends during tool round trips. ### DeepSeek response @@ -111,4 +121,5 @@ Loop-retained response blocks append to the next request and preserve its earlie - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. - **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`). -- **Serialization flattens user and tool-result content to text blocks** — plugin-added block types are skipped, and empty tool output crosses the wire as the literal `(no output)`. +- **Plugin-added content block types are skipped** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`. +- **Images are input-only durable attachments** — direct external URLs, the Files API, and assistant image output are not supported. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 21c55cfa32..b1a45a1f39 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -20,6 +20,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: reasoningEffort: high # optional; off | low | high | max — omitted ⇒ high maxTokens: 256000 # optional positive per-request output cap; this is the default streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default + maxRequestImageBytes: 20971520 # optional positive integer; 20 MiB base64-payload default retryPolicy: # optional; omission uses normal mode with five retries mode: always # normal | always backoff: @@ -30,12 +31,19 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek-V4-Flash + - id: private-vision + name: Private Vision + inputModalities: [text, image] - id: private-reasoner description: Company-hosted reasoning model contextWindow: 512000 ``` -该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash`(名称为 `DeepSeek-V4-Flash`)和 `deepseek-v4-pro`(名称为 `DeepSeek-V4-Pro`),两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id。 +该插件注册唯一提供方路由 `deepseek-official`,并一同注册解析后的 `retryPolicy`;省略时会解析为 normal 模式并重试五次。请求使用 `provider: deepseek-official` 选择该路由;其 `model` 会作为协议 `model` 字符串原样传递,因此更改 DeepSeek 模型不需要生命周期时注册。省略 `models` 会公布 `deepseek-v4-flash` 和 `deepseek-v4-pro`,两者的上下文窗口均为 1,000,000 token;显式列表会替换这些默认值,`models: []` 则不公布任何模型。在视觉模型端点完成发布前,默认目录不会公布视觉模型,但部署方可以通过 `inputModalities: [text, image]` 主动添加。Catalog 配置项通过 `ctx.llm.listModels('deepseek-official')` 公开给 ACP(Agent Client Protocol)编辑器和 Web 选择器等客户端,但仍只提供建议:未列出模型 id 仍原样传递。省略配置项 name 默认为其 id,省略 `inputModalities` 则表示仅支持 `text`。 + +支持图片的 catalog 配置项可以声明 `inputModalities: [text, image]`。适配器通过 `ctx.attachments` 解析 user 和工具结果中的 `ImageBlock` 引用,校验已存储字节,再发送瞬态 `data:;base64,...` `image_url` 部分,不改变持久会话消息。纯文本模型与未列出模型会在凭据、附件或网络 I/O 前拒绝图片输入。System 和 assistant 历史仍不能包含图片;工具结果图片会在仅含字符串的 `tool` 消息后,通过单独的 `user` 消息发送。 + +`maxRequestImageBytes` 限制累计 base64 图片 payload,默认值为 20 MiB,为官方 30 MiB 请求正文限制中的文本、工具和 JSON 分帧保留余量。历史超过上限时,适配器会从最旧图片开始替换为固定模型可见占位文本 `[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]`,直至请求可容纳;被省略的附件不会被读取。附件准入仍负责单图和单消息原始字节数、媒体类型、尺寸与像素限制。 `contextWindow` 对每个已配置模型都可选,不会通过建议 catalog 公开。`ctx.llm.resolveModelInfo('deepseek-official', model).context` 先返回精确模型值,再对不含容量的配置项或未列出原样传递 id 返回 `defaultContextWindow`。适配器默认值为 1,000,000;因此,压力敏感插件可以获得由部署决定的容量,不会将模型 selector 视为权威。为 `deepseek-official` 注册另一个适配器会抛出 `LlmError('DUPLICATE_ADAPTER')`。 @@ -49,10 +57,11 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: ## 动态配置(settings + credentials) -连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: +连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值、图片上限与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。三个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 - **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照。配置只携带 `apiKeyEnv`,从不携带字面密钥:该引用经凭据 seam 解析,未挂载 seam 时则经受信环境层解析。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个解析出的密钥在使用前都会被校验格式,因此 HTTP 标头无法承载的值会以 `LlmError('INVALID_CREDENTIAL')` 被拒绝,点名失败的入口,但绝不透露密钥的任何部分,而不是以语义不明的 `fetch` `TypeError` 形式浮现。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.attachments`**——图片请求会在请求时解析该服务,因此 Cordis 加载顺序不会冻结可选图片能力。服务缺失时,图片输入以 `UNSUPPORTED_CONTENT` 失败;纯文本调用不依赖该服务。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 @@ -70,11 +79,12 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 - 适配器持有的 `off` 推理强度映射为 `thinking: {type: 'disabled'}`,绝不会以 `reasoning_effort: 'off'` 通过协议发送。 - 第一个思考模式分片携带 `reasoning_content: ""`,系统会处理它(不会产生多余 reasoning 块)。 - **推理回传规则**:对携带工具调用的 assistant 轮次,会将 `reasoning_content` 序列化回历史(思考模式 API 必需);对不含工具调用的轮次,它会被丢弃(不会使用,可节省 token)。 +- 支持图片的 user 消息会保留文本/图片顺序。Tool role 内容仍为字符串;连续工具结果中的图片会用 `Attached image(s) from tool result:` 汇总到随后一条 user 消息。 - Cache 计量:`cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`;DeepSeek 不报告 cache-write 指标。 ## 错误 -非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400)、`SERVER`(5xx),其他情况为 `HTTP_`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 +非 2xx 响应会抛出稳定 code 的 `LlmError`:`AUTH`(401/403)、`QUOTA`(提供方详细信息标识配额、余额或点数耗尽的响应)、`RATE_LIMIT`(其他 429)、`CONTEXT_WINDOW_EXCEEDED`(提供方 code、type 或 message 标识上下文溢出的 400)、`INVALID_REQUEST`(其他 400 和 413)、`SERVER`(5xx),其他情况为 `HTTP_`。其可序列化 `failure` 保留 HTTP 状态,以及有效的正 `Retry-After` 秒数/日期延迟和存在时的 `x-request-id` / `x-deepseek-request-id`。附件读取会保留稳定的附件失败 code,不会变成传输失败。响应前传输失败(DNS、连接被拒绝、TLS、proxy)会抛出命名已配置端点的 `TRANSPORT`,并将原始拒绝作为 `cause`;调用方 abort 抛出 `ABORTED`,仍以 loop 的取消信号为准。协议违例抛出 `STREAM_CLOSED`(没有 `[DONE]`)或 `MALFORMED_RESPONSE`(JSON payload 格式错误)。未知协议 `finish_reason`(例如 `content_filter`、`insufficient_system_resource`)会变为 `finish {kind: 'error', failure}` 分片;已完成流如果使用 `stop`(或缺失)finish 但没有开启内容块,就会变为 `finish {kind: 'error'}`,code 为 `EMPTY_RESPONSE`(默认策略会重试)。 ## 模型体验 @@ -82,15 +92,15 @@ DeepSeek 请求身份独立于应用归因。凭据解析成功后,每个提 #### 模型看到的内容 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。当之前的 assistant 轮次包含工具调用时,会按要求回传其推理内容;不含工具调用的轮次会省略推理。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、stop sequence 和调用配置,不含适配器撰写的提示词文本。视觉模型还会通过 base64 data URL 收到保留的 user 与工具结果图片;超出上限的较旧图片由已记录的占位文本表示。当之前的 assistant 轮次包含工具调用时,会按要求回传其推理内容;不含工具调用的轮次会省略推理。 #### Token 影响 -精确输入取决于提供方 tokenization。有条件推理回传会增加工具往返上下文,丢弃其他推理则避免再次支付这些 token;可用时会报告 cache-read 用量。 +精确文本与图片 token 输入取决于提供方 tokenization。有条件推理回传会增加工具往返上下文,丢弃其他推理和超出上限的图片则避免再次支付这些 token;可用时会报告 cache-read 用量。 #### KV Cache 影响 -未更改的已组装前缀可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀或历史变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在工具往返期间追加。 +未更改的已组装前缀,包括确定性编码的保留图片与占位文本,可使用 DeepSeek cache 复用,适配器会在 usage 中报告它。模型路由变更,或任何上游提示词、schema、前缀、历史或图片上限变更,都可能使从首个发生变化的 token 起的复用失效;推理回传会在工具往返期间追加。 ### DeepSeek 响应 @@ -111,4 +121,5 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用 - **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。 - **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。 -- **序列化会将 user 与工具结果内容展平为文本块**:会跳过插件添加的块类型,空工具输出会以字面 `(no output)` 通过协议发送。 +- **会跳过插件添加的内容块类型**:核心文本与支持的图片块会被序列化,空工具输出会以字面 `(no output)` 通过协议发送。 +- **图片是仅输入的持久附件**:不支持直接外部 URL、Files API 和 assistant 图片输出。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 36ccad7994..52d022806a 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -32,6 +32,7 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -46,6 +47,7 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index bc30d46c3b..638d555b1e 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -8,19 +8,21 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, contentHasImage, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, + ModelModality, ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' -import { serializeRequest } from './serialize.ts' +import { serializeRequest, serializeRequestWithImages } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' import { parseSse } from './sse.ts' import { translate } from './translate.ts' @@ -38,6 +40,8 @@ export interface DeepSeekCatalogModel { contextWindow?: number /** Per-request output cap for this model; omission falls back to the profile's {@link DeepSeekConnectionOptions.maxTokens}. */ maxTokens?: number + /** Accepted request modalities; omission is text-only. */ + inputModalities?: ModelModality[] } /** @@ -66,6 +70,8 @@ export interface DeepSeekConnectionOptions { models: readonly DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding. */ streamIdleTimeoutMs: number + /** Maximum accumulated base64 image payload in one request. */ + maxRequestImageBytes: number /** Provider-owned model-request retry policy, already resolved. */ retryPolicy: ResolvedRetryPolicy } @@ -83,6 +89,8 @@ export interface DeepSeekAdapterOptions { resolveApiKey: (connection: DeepSeekConnectionOptions) => Promise /** Resolve the harness-home anonymous id shared with telemetry and feedback. */ resolveUserId: () => AnonymousUserId + /** Resolve the current durable attachment service; absence rejects image input. */ + resolveAttachments?: () => AttachmentStore | undefined } /** Default maximum idle interval while an adapter stream read is outstanding. */ @@ -91,6 +99,8 @@ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 export const DEFAULT_CONTEXT_WINDOW = 1_000_000 /** Default per-request output-token cap. */ export const DEFAULT_MAX_TOKENS = 256_000 +/** Default bound on accumulated base64 image payload per request. */ +export const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' const OFF_REASONING_EFFORT = ReasoningEffortId('off') const LOW_REASONING_EFFORT = ReasoningEffortId('low') @@ -112,7 +122,7 @@ function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo id: model.id, name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, - inputModalities: ['text'], + inputModalities: model.inputModalities ?? ['text'], } } @@ -139,6 +149,7 @@ function requestId(headers: Headers): ReturnType | und */ export function httpErrorCode(status: number, error?: WireError['error']): string { if (status === 401 || status === 403) return 'AUTH' + if (status === 413) return 'INVALID_REQUEST' const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ') if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE if (status === 429) return 'RATE_LIMIT' @@ -184,10 +195,9 @@ export class DeepSeekAdapter extends LlmAdapter { const contextWindow = configured?.contextWindow ?? connection.defaultContextWindow return Promise.resolve({ - // The chat-completions wire route is text-only regardless of catalog - // membership, so the uncatalogued fallback declares the same negative - // capability — "unknown" here would let the host accept and persist - // images the serializer must then reject. + // An uncatalogued endpoint is safely treated as text-only. Declaring an + // unverified image capability would let the host persist input that the + // endpoint may reject on every later turn. ...configured === undefined ? { provider, id: model, name: model, inputModalities: ['text' as const] } : modelInfo(provider, configured), @@ -222,6 +232,24 @@ export class DeepSeekAdapter extends LlmAdapter { // The key resolves *from this snapshot*, so an endpoint and the secret // sent to it can never come from different configuration generations. const connection = this.config.options() + const hasImages = options.messages.some(message => contentHasImage(message.content)) + let attachments: AttachmentStore | undefined + if (hasImages) { + const model = connection.models.find(entry => entry.id === options.model) + if (model?.inputModalities?.includes('image') !== true) { + throw new LlmError( + `DeepSeek model "${options.model}" does not accept image input.`, + 'UNSUPPORTED_CONTENT', + ) + } + attachments = this.config.resolveAttachments?.() + if (attachments === undefined) { + throw new LlmError( + 'DeepSeek image conversion requires the durable attachment service.', + 'UNSUPPORTED_CONTENT', + ) + } + } const apiKey = await this.config.resolveApiKey(connection) const userId = this.config.resolveUserId() const consumer = new AbortController() @@ -235,6 +263,7 @@ export class DeepSeekAdapter extends LlmAdapter { connection, apiKey, userId, + attachments, () => { watchdog.pulse() }, )[Symbol.asyncIterator]() let exhausted = false @@ -278,9 +307,16 @@ export class DeepSeekAdapter extends LlmAdapter { connection: DeepSeekConnectionOptions, apiKey: string, userId: AnonymousUserId, + attachments: AttachmentStore | undefined, onComment: () => void, ): AsyncIterable { - const body = serializeRequest(options, connection.defaults) + const body = attachments === undefined + ? serializeRequest(options, connection.defaults) + : await serializeRequestWithImages(options, { + attachments, + maxRequestImageBytes: connection.maxRequestImageBytes, + signal, + }, connection.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the // transport boundary, never a serialization failure. const payload = JSON.stringify(body) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index cbaa0c13b6..51382bb128 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -14,7 +14,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' -import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' +import type { ModelModality, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { launchEnvironmentOf, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -22,6 +22,7 @@ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getOrCreateAnonymousUserId, type AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { DEFAULT_CONTEXT_WINDOW, + DEFAULT_MAX_REQUEST_IMAGE_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter, @@ -30,6 +31,7 @@ import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter. export { DEFAULT_CONTEXT_WINDOW, + DEFAULT_MAX_REQUEST_IMAGE_BYTES, DEFAULT_MAX_TOKENS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter, @@ -51,6 +53,8 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ { id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', contextWindow: DEFAULT_CONTEXT_WINDOW }, ] +const MODEL_MODALITIES = ['text', 'image'] as const satisfies readonly ModelModality[] + /** * Plugin config, validated by the same-named schemastery schema and doubling * as the `llm-deepseek` settings-section shape. Every field is optional in @@ -76,6 +80,8 @@ export interface Config { models?: DeepSeekCatalogModel[] /** Maximum provider idle time while one stream read is outstanding (default five minutes). */ streamIdleTimeoutMs?: number + /** Maximum accumulated base64 image payload per request (default 20 MiB). */ + maxRequestImageBytes?: number /** Provider-owned model-request retry policy; omission uses normal mode with five retries. */ retryPolicy?: RetryPolicyConfig } @@ -86,6 +92,7 @@ const catalogModel: z = z.object({ description: z.string(), contextWindow: z.number().step(1).min(1), maxTokens: z.number().step(1).min(1), + inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(['text']), }) export const Config: z = z.object({ @@ -97,6 +104,7 @@ export const Config: z = z.object({ defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), models: z.array(catalogModel).default(DEFAULT_MODELS), streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS), + maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES), retryPolicy: RetryPolicySchema, }) @@ -134,6 +142,18 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee `llm-deepseek: catalog model "${model.id}" maxTokens must be a positive integer`, ) } + const inputModalities = model.inputModalities ?? ['text'] + if (inputModalities.length === 0) { + throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not be empty`) + } + if (inputModalities.some(modality => !MODEL_MODALITIES.includes(modality))) { + throw new Error( + `llm-deepseek: catalog model "${model.id}" inputModalities must contain only "text" and "image"`, + ) + } + if (new Set(inputModalities).size !== inputModalities.length) { + throw new Error(`llm-deepseek: catalog model "${model.id}" inputModalities must not contain duplicates`) + } if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`) seen.add(model.id) return { @@ -142,6 +162,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee ...model.description === undefined ? {} : { description: model.description }, ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens }, + inputModalities: [...inputModalities], } }) } @@ -180,6 +201,10 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`, ) } + const maxRequestImageBytes = config.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES + if (!Number.isSafeInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) { + throw new Error('llm-deepseek: maxRequestImageBytes must be a positive safe integer') + } return { apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL @@ -193,6 +218,7 @@ export function resolveAdapterOptions(config: Config, environment?: LaunchEnviro defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, models: resolveModels(config.models), streamIdleTimeoutMs, + maxRequestImageBytes, retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-deepseek: retryPolicy'), } } @@ -247,7 +273,12 @@ export function apply(ctx: Context, config: Config): void { let userId: AnonymousUserId | undefined const resolveUserId = (): AnonymousUserId => userId ??= getOrCreateAnonymousUserId() - const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId }) + const adapter = new DeepSeekAdapter({ + options, + resolveApiKey, + resolveUserId, + resolveAttachments: () => ctx.get('attachments'), + }) ctx.llm.registerConfigurableProviders([ { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] }, ]) diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index f51ef23031..bdf9869544 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -1,15 +1,22 @@ /** - * Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text - * becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages. - * Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by - * thinking-mode passback. Core image blocks are rejected explicitly because this wire route is text-only; - * unknown declaration-merged block types retain the adapter's documented extension fallback. + * Serialize harness messages into DeepSeek chat completions. Text-only + * requests retain string user content; the image path resolves durable + * attachments into ordered data-URL parts. Tool-result images follow their + * string-only tool messages in a separate user message. * @module dsh-llm-deepseek/serialize */ -import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError, offloadRequestImages } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { WireMessage, WireRequest, WireTool } from './types.ts' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + WireImageContentPart, + WireMessage, + WireRequest, + WireTool, + WireUserContentPart, +} from './types.ts' /** Adapter-level request defaults (from plugin config). */ export interface RequestDefaults { @@ -22,6 +29,18 @@ interface ResolvedThinking { reasoningEffort?: 'low' | 'high' | 'max' } +/** Dependencies required only when the request contains image input. */ +export interface ImageSerializationOptions { + /** Durable resolver for canonical image references. */ + attachments: AttachmentStore + /** Positive bound on accumulated base64 image payload. */ + maxRequestImageBytes: number + /** Cancellation shared with the provider request. */ + signal: AbortSignal +} + +const TOOL_RESULT_IMAGE_TEXT = 'Attached image(s) from tool result:' + /** Validate the adapter-owned effort before resolving its DeepSeek wire fields. */ function reasoningEffort(effort: NonNullable): 'off' | 'low' | 'high' | 'max' { if (effort === 'off' || effort === 'low' || effort === 'high' || effort === 'max') { @@ -67,6 +86,73 @@ function assertTextOnly(blocks: readonly ContentBlock[]): void { } } +/** Reject roles whose DeepSeek history format cannot carry image input. */ +function assertSupportedImageRoles(messages: readonly Message[]): void { + for (const message of messages) { + if (message.role !== 'user' && contentHasImage(message.content)) { + throw new LlmError( + `The DeepSeek chat-completions adapter cannot represent image content in a ${message.role} message.`, + 'UNSUPPORTED_CONTENT', + ) + } + } +} + +/** Resolve one durable image into its transient DeepSeek data-URL part. */ +async function imagePart( + block: Extract, + attachments: AttachmentStore, + signal: AbortSignal, +): Promise { + try { + const stored = await attachments.readImage(block.attachment, signal) + return { + type: 'image_url', + image_url: { + url: `data:${stored.ref.mediaType};base64,${Buffer.from(stored.data).toString('base64')}`, + }, + } + } catch (error: unknown) { + if (error instanceof AttachmentError) { + throw new LlmError(error.message, error.code, { cause: error }) + } + throw error + } +} + +/** Convert user or nested tool-result blocks into ordered wire parts. */ +async function contentParts( + blocks: readonly ContentBlock[], + attachments: AttachmentStore, + signal: AbortSignal, +): Promise { + const parts: WireUserContentPart[] = [] + for (const block of blocks) { + switch (block.type) { + case 'text': + if (block.text.length > 0) parts.push({ type: 'text', text: block.text }) + break + case 'image': + parts.push(await imagePart(block, attachments, signal)) + break + case 'tool-result': + parts.push(...await contentParts(block.content, attachments, signal)) + break + default: + // Other merge-extensible blocks are not DeepSeek user-input vocabulary. + break + } + } + return parts +} + +/** Keep text-only user messages on the compact string wire form. */ +function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] { + return parts.some(part => part.type === 'image_url') + ? [...parts] + : parts.map(part => part.type === 'text' ? part.text : '').join('') +} + /** Serialize one assistant message (text + reasoning + tool calls). */ function serializeAssistant(message: Message): WireMessage { const text = flattenText(message.content) @@ -140,6 +226,102 @@ export function serializeMessages(messages: Message[]): WireMessage[] { return wire } +/** + * Serialize image-capable history after resolving durable attachments. + * Consecutive tool results keep string `tool` messages and share one following + * user message containing their images. + * @param messages - transient request history after request-size offloading. + * @param attachments - durable image resolver. + * @param signal - cancellation for attachment reads. + * @returns ordered DeepSeek wire messages. + */ +export async function serializeMessagesWithImages( + messages: readonly Message[], + attachments: AttachmentStore, + signal: AbortSignal, +): Promise { + assertSupportedImageRoles(messages) + const wire: WireMessage[] = [] + let pendingToolImages: WireImageContentPart[] = [] + const flushToolImages = (): void => { + if (pendingToolImages.length === 0) return + wire.push({ + role: 'user', + content: [{ type: 'text', text: TOOL_RESULT_IMAGE_TEXT }, ...pendingToolImages], + }) + pendingToolImages = [] + } + + for (const message of messages) { + if (message.role === 'system') { + flushToolImages() + wire.push({ role: 'system', content: flattenText(message.content) }) + continue + } + if (message.role === 'assistant') { + flushToolImages() + wire.push(serializeAssistant(message)) + continue + } + + const regular = message.content.filter(block => block.type !== 'tool-result') + const toolResults = message.content.filter((block): block is Extract => ( + block.type === 'tool-result' + )) + if (regular.length > 0 || toolResults.length === 0) { + flushToolImages() + wire.push({ + role: 'user', + content: userContent(await contentParts(regular, attachments, signal)), + }) + } + for (const result of toolResults) { + const parts = await contentParts(result.content, attachments, signal) + const images = parts.filter((part): part is WireImageContentPart => part.type === 'image_url') + const text = parts.filter(part => part.type === 'text').map(part => part.text).join('') + wire.push({ + role: 'tool', + tool_call_id: result.toolCallId, + content: text || (images.length > 0 ? '(see attached image)' : '(no output)'), + }) + pendingToolImages.push(...images) + } + } + flushToolImages() + return wire +} + +/** Assemble request fields shared by text-only and image-capable conversion. */ +function requestWithMessages( + options: GenerateOptions, + messages: WireMessage[], + defaults: RequestDefaults, +): WireRequest { + const tools: WireTool[] | undefined = options.tools?.map(tool => ({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + })) + const resolvedThinking = resolveThinking(options, defaults) + return { + model: options.model, + messages, + stream: true, + stream_options: { include_usage: true }, + ...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {}, + ...resolvedThinking.reasoningEffort !== undefined + ? { reasoning_effort: resolvedThinking.reasoningEffort } + : {}, + ...tools !== undefined && tools.length > 0 ? { tools } : {}, + ...options.temperature !== undefined ? { temperature: options.temperature } : {}, + ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }, + ...options.stop !== undefined ? { stop: options.stop } : {}, + } +} + /** * Build the full wire request. Always streaming (`stream: true`, usage * reporting on); optional fields are omitted rather than sent as null, so @@ -158,30 +340,29 @@ export function serializeRequest( } messages.push(...serializeMessages(options.messages)) - const tools: WireTool[] | undefined = options.tools?.map(tool => ({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - })) - // A short title budget must produce visible text; conversation and - // compaction calls continue to inherit the adapter's thinking defaults. - const resolvedThinking = resolveThinking(options, defaults) + return requestWithMessages(options, messages, defaults) +} - return { - model: options.model, - messages, - stream: true, - stream_options: { include_usage: true }, - ...resolvedThinking.thinking !== undefined ? { thinking: { type: resolvedThinking.thinking } } : {}, - ...resolvedThinking.reasoningEffort !== undefined - ? { reasoning_effort: resolvedThinking.reasoningEffort } - : {}, - ...tools !== undefined && tools.length > 0 ? { tools } : {}, - ...options.temperature !== undefined ? { temperature: options.temperature } : {}, - ...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }, - ...options.stop !== undefined ? { stop: options.stop } : {}, +/** + * Build one image-capable request while keeping durable bytes out of session + * messages. Oversized oldest images become deterministic text before any + * attachment read. + * @param options - harness request containing image-capable user content. + * @param images - attachment resolver, request bound, and cancellation. + * @param defaults - adapter-level thinking defaults. + * @returns the fully materialized DeepSeek request body. + */ +export async function serializeRequestWithImages( + options: GenerateOptions, + images: ImageSerializationOptions, + defaults: RequestDefaults = {}, +): Promise { + assertSupportedImageRoles(options.messages) + const requestMessages = offloadRequestImages(options.messages, images.maxRequestImageBytes) + const messages: WireMessage[] = [] + if (options.system !== undefined) { + messages.push({ role: 'system', content: options.system }) } + messages.push(...await serializeMessagesWithImages(requestMessages, images.attachments, images.signal)) + return requestWithMessages(options, messages, defaults) } diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index ec3f28cbf4..1781ed8646 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -35,10 +35,25 @@ export interface WireSystemMessage { content: string } -/** User-role message: a single string of user input. */ +/** Text part inside a multimodal user message. */ +export interface WireTextContentPart { + type: 'text' + text: string +} + +/** Base64 data URL part inside a multimodal user message. */ +export interface WireImageContentPart { + type: 'image_url' + image_url: { url: string } +} + +/** Ordered input part accepted by a multimodal user message. */ +export type WireUserContentPart = WireTextContentPart | WireImageContentPart + +/** User-role message: text-only string or ordered multimodal input. */ export interface WireUserMessage { role: 'user' - content: string + content: string | WireUserContentPart[] } /** Tool-role message: the result of one tool call, keyed by its call id. */ diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 19ce411406..7845157fab 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,10 +1,18 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' @@ -18,6 +26,41 @@ import { assemble, type AssembledResult } from './assemble.ts' const FLASH = 'deepseek-v4-flash' const PRO = 'deepseek-v4-pro' +const VISION = 'deepseek-v4-flash-vision-exp' +const RED_IMAGE = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', + 'base64', +) +const RED_IMAGE_REF: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${createHash('sha256').update(RED_IMAGE).digest('hex')}`), + mediaType: 'image/png', + bytes: RED_IMAGE.byteLength, + width: 1, + height: 1, +} + +class E2eAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 1, + maxImageDimension: 1, + mediaTypes: ['image/png'], + } + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(RED_IMAGE_REF) + } + + readImage(_ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { + return Promise.resolve({ ref: RED_IMAGE_REF, data: RED_IMAGE }) + } +} const contexts: Context[] = [] let identityHome: string @@ -30,6 +73,7 @@ async function harness(_model: string, config: Partial = {}) { const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmRuntime) + await ctx.plugin(E2eAttachmentStore) await ctx.plugin(LlmDeepSeek, config) return ctx } @@ -65,6 +109,23 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { + it('recognizes a deterministic image with the official vision model', async () => { + const ctx = await harness(VISION, { thinking: 'disabled' }) + const result = await assemble(ctx, { + model: VISION, + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'This image is one solid color. Reply with only its English color name.' }, + { type: 'image', attachment: RED_IMAGE_REF }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + maxTokens: 50, + }) + expect(result.finish.kind).toBe('stop') + expect(textOf(result).toLowerCase()).toContain('red') + }) + it('serves a real request with the key held only by a credentials-local document', async () => { const key = process.env.DEEPSEEK_API_KEY if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index ce56635ee7..cecb37bdff 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -3,6 +3,8 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import LlmRuntime, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, @@ -47,15 +49,31 @@ async function harness(baseURL: string, config: object = {}) { } /** Direct adapter over the plugin's real resolve step, with a static key. */ -function adapterOf(config: Partial & { apiKey?: string } = {}): DeepSeekAdapter { +function adapterOf( + config: Partial & { apiKey?: string } = {}, + attachments?: AttachmentStore, +): DeepSeekAdapter { const { apiKey, ...rest } = config return new DeepSeekAdapter({ options: () => resolveAdapterOptions(rest), resolveApiKey: () => Promise.resolve(apiKey ?? 'k'), resolveUserId: () => TEST_USER_ID, + resolveAttachments: () => attachments, }) } +async function drain(stream: AsyncIterable): Promise { + for await (const _chunk of stream) { /* drain */ } +} + +const imageRef: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, +} + describe('DeepSeekAdapter against a mock server', () => { it('streams a text generation end to end through the assembler', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) @@ -90,6 +108,93 @@ describe('DeepSeekAdapter against a mock server', () => { expect(server.headers[0]).not.toHaveProperty('x-deepseek-harness-compact') }) + it('sends a durable image as a base64 data URL for the vision model', async () => { + const server = await mockServer([{ kind: 'sse', events: textEvents }]) + const signalSeen: (AbortSignal | undefined)[] = [] + const attachments = { + readImage: vi.fn((ref: ImageAttachmentRef, signal?: AbortSignal) => { + signalSeen.push(signal) + return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) + }), + } as unknown as AttachmentStore + const adapter = adapterOf({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }, attachments) + + await drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'describe ' }, + { type: 'image', attachment: imageRef }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + })) + + expect(server.requests[0]).toMatchObject({ + model: 'deepseek-v4-flash-vision-exp', + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'describe ' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }, + ], + }], + }) + expect(signalSeen[0]).toBeInstanceOf(AbortSignal) + }) + + it.each(['deepseek-v4-flash', 'unlisted-pass-through'])( + 'rejects image input for text-only model %s before credentials, attachments, or fetch', + async (model) => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const resolveAttachments = vi.fn(() => ({}) as AttachmentStore) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + resolveAttachments, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model, + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(resolveAttachments).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }, + ) + + it('rejects vision input without an attachment provider before credentials or fetch', async () => { + const server = await mockServer([]) + const resolveApiKey = vi.fn(() => Promise.resolve('k')) + const adapter = new DeepSeekAdapter({ + options: () => resolveAdapterOptions({ baseURL: server.url }), + resolveApiKey, + resolveUserId: () => TEST_USER_ID, + }) + + await expect(drain(adapter.stream({ + provider: 'deepseek-official', + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }))).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(server.requests).toHaveLength(0) + }) + it('streams raw chunks through ctx.llm.stream', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents, delayMs: 2 }]) const ctx = await harness(server.url) @@ -386,7 +491,7 @@ describe('DeepSeekAdapter against a mock server', () => { .toBe(CONTEXT_WINDOW_EXCEEDED_CODE) expect(httpErrorCode(400, { message: 'invalid input: temperature exceeds maximum allowed value' })) .toBe('INVALID_REQUEST') - expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413') + expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('INVALID_REQUEST') }) it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => { @@ -772,12 +877,13 @@ describe('plugin registration and config', () => { name: 'Private Reasoner', description: 'Higher reasoning budget', contextWindow: 64_000, + inputModalities: ['text', 'image'], }, ], }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, - { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text', 'image'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast')) .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) @@ -785,6 +891,7 @@ describe('plugin registration and config', () => { .resolves.toMatchObject({ name: 'Private Reasoner', description: 'Higher reasoning budget', + inputModalities: ['text', 'image'], }) await expect(ctx.llm.resolveModelInfo('deepseek-official', 'arbitrary-unlisted')) .resolves.toMatchObject({ @@ -823,13 +930,21 @@ describe('plugin registration and config', () => { await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([]) }) - it.each([ + const invalidModels: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ [[{ id: '' }], /ids must be non-empty/], [[{ id: 'm', name: '' }], /empty name/], [[{ id: 'm', contextWindow: 0 }], /contextWindow/], [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], + [[{ id: 'm', inputModalities: [] }], /inputModalities/], + [[{ id: 'm', inputModalities: ['text', 'text'] }], /inputModalities must not contain duplicates/], + [[{ + id: 'm', + inputModalities: ['audio'] as unknown as NonNullable, + }], /expected "text" \| "image"/], [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], - ] as const)('rejects invalid advisory model config', async (models, message) => { + ] + + it.each(invalidModels)('rejects invalid advisory model config', async (models, message) => { const ctx = new Context() await ctx.plugin(LlmRuntime) await expect(ctx.plugin(LlmDeepSeek, { @@ -903,6 +1018,22 @@ describe('plugin registration and config', () => { }, ) + it.each([0, 1.5, Number.MAX_SAFE_INTEGER + 1])( + 'rejects invalid request image bound %s', + async (maxRequestImageBytes) => { + expect(() => resolveAdapterOptions({ maxRequestImageBytes })) + .toThrow(/maxRequestImageBytes must be a positive safe integer/) + + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await expect(ctx.plugin(LlmDeepSeek, { + baseURL: 'http://127.0.0.1:1', + maxRequestImageBytes, + })).rejects.toThrow(/maxRequestImageBytes/) + expect(ctx.llm.listProviders()).toEqual([]) + }, + ) + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 84e78819b0..be7d4e3688 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -3,7 +3,14 @@ import { Context } from '@deepseek-ai/cordis' import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import LlmRuntime, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createUserMessage, INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm' +import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -14,6 +21,36 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const NS = settingsNamespace('llm-deepseek') const KEY_REF = credentialRef('DEEPSEEK_API_KEY') +const IMAGE_REF: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 3, + width: 1, + height: 1, +} + +class StaticAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 16, + maxImagesPerMessage: 4, + maxMessageImageBytes: 64, + maxImagePixels: 4, + maxImageDimension: 4, + mediaTypes: ['image/png'], + } + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.resolve(IMAGE_REF) + } + + readImage(ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { + return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) + } +} const cleanups: Array<() => Promise> = [] @@ -47,6 +84,7 @@ async function boot(dir: string, config: object): Promise { await ctx.fiber.dispose() }) await ctx.plugin(LlmRuntime) + await ctx.plugin(StaticAttachmentStore) const settingsFiber = ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false }) await settingsFiber await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) @@ -118,12 +156,44 @@ describe('request-level dynamic configuration', () => { const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) - await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) + await ctx.settings.update(NS, { + models: [{ id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] }], + }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'settings-model', name: 'From Settings', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'settings-model', name: 'From Settings', inputModalities: ['text', 'image'] }, ]) }) + it('applies a changed request image bound to the next request', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const dir = await home() + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const { ctx } = await boot(dir, { + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }) + const messages = [createUserMessage({ + content: [ + { type: 'image', attachment: IMAGE_REF }, + { type: 'image', attachment: IMAGE_REF }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })] + + await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) + await ctx.settings.update(NS, { maxRequestImageBytes: 4 }) + await assemble(ctx, { model: 'deepseek-v4-flash-vision-exp', messages }) + + const first = (server.requests[0] as { messages: Array<{ content: unknown }> }).messages[0]?.content + const second = (server.requests[1] as { messages: Array<{ content: unknown }> }).messages[0]?.content + expect(JSON.stringify(first).match(/"type":"image_url"/g)).toHaveLength(2) + expect(JSON.stringify(second)).toContain('[image omitted to keep the request within its image limit') + expect(JSON.stringify(second).match(/"type":"image_url"/g)).toHaveLength(1) + }) + it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => { const dir = await home() const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 714cfc5d27..059d6c0a54 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,13 +1,38 @@ -import { describe, expect, it } from 'vitest' -import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { describe, expect, it, vi } from 'vitest' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import { createUserMessage, CallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { serializeMessages, serializeRequest } from '../src/serialize.ts' +import { + serializeMessages, + serializeMessagesWithImages, + serializeRequest, + serializeRequestWithImages, +} from '../src/serialize.ts' function request(overrides: Partial = {}): GenerateOptions { return { provider: 'deepseek-official', model: 'deepseek-v4-flash', messages: [], ...overrides } } +function imageRef(mediaType: ImageMediaType = 'image/png', bytes = 3): ImageAttachmentRef { + return { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType, + bytes, + width: 1, + height: 1, + } +} + +function attachmentStore( + readImage = vi.fn((ref: ImageAttachmentRef, _signal?: AbortSignal) => Promise.resolve({ + ref, + data: Uint8Array.of(1, 2, 3), + })), +): AttachmentStore { + return { readImage } as unknown as AttachmentStore +} + describe('serializeMessages', () => { it('maps user text to string content', () => { const wire = serializeMessages([ @@ -270,6 +295,159 @@ describe('serializeRequest', () => { }) }) +describe('image serialization', () => { + it.each([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', + ] as const)('preserves ordered text and %s image parts', async (mediaType) => { + const signal = new AbortController().signal + const readImage = vi.fn((ref: ImageAttachmentRef, received?: AbortSignal) => { + expect(received).toBe(signal) + return Promise.resolve({ ref, data: Uint8Array.of(1, 2, 3) }) + }) + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'text', text: 'before' }, + { type: 'image', attachment: imageRef(mediaType) }, + { type: 'text', text: 'after' }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(readImage), + maxRequestImageBytes: 20 * 1024 * 1024, + signal, + }) + + expect(wire.messages).toEqual([{ + role: 'user', + content: [ + { type: 'text', text: 'before' }, + { type: 'image_url', image_url: { url: `data:${mediaType};base64,AQID` } }, + { type: 'text', text: 'after' }, + ], + }]) + }) + + it('serializes image-only user content without synthetic text', async () => { + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(), + maxRequestImageBytes: 20 * 1024 * 1024, + signal: new AbortController().signal, + }) + + expect(wire.messages).toEqual([{ + role: 'user', + content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }], + }]) + }) + + it('keeps tool content textual and groups consecutive tool-result images afterward', async () => { + const messages = [ + createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('first'), + content: [{ type: 'image', attachment: imageRef() }], + }], + source: { kind: 'plugin', plugin: 'test' }, + }), + createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('second'), + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', attachment: imageRef('image/jpeg') }, + ], + }], + source: { kind: 'plugin', plugin: 'test' }, + }), + ] + + await expect(serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + )).resolves.toEqual([ + { role: 'tool', tool_call_id: 'first', content: '(see attached image)' }, + { role: 'tool', tool_call_id: 'second', content: 'caption' }, + { + role: 'user', + content: [ + { type: 'text', text: 'Attached image(s) from tool result:' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }, + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AQID' } }, + ], + }, + ]) + }) + + it('offloads oldest images before reads and keeps the newest image', async () => { + const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ + ref, + data: Uint8Array.of(1, 2, 3), + })) + const wire = await serializeRequestWithImages(request({ + model: 'deepseek-v4-flash-vision-exp', + messages: [createUserMessage({ + content: [ + { type: 'image', attachment: imageRef('image/png', 3) }, + { type: 'image', attachment: imageRef('image/jpeg', 3) }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(readImage), + maxRequestImageBytes: 4, + signal: new AbortController().signal, + }) + + expect(wire.messages[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'text', text: expect.stringContaining('older images are omitted first') as string }, + { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,AQID' } }, + ], + }) + expect(readImage).toHaveBeenCalledTimes(1) + expect(readImage.mock.calls[0]?.[0]).toMatchObject({ mediaType: 'image/jpeg' }) + }) + + it.each(['system', 'assistant'] as const)('rejects an image in %s history before reading attachments', async (role) => { + const readImage = vi.fn() + await expect(serializeMessagesWithImages([createMessage({ + role, + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], attachmentStore(readImage), new AbortController().signal)) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(readImage).not.toHaveBeenCalled() + }) + + it('preserves stable attachment failure codes', async () => { + const readImage = vi.fn(() => Promise.reject(new AttachmentError( + 'Stored attachment bytes are corrupt.', + 'ATTACHMENT_CORRUPT', + ))) + await expect(serializeMessagesWithImages([createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], attachmentStore(readImage), new AbortController().signal)) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) +}) + describe('review fixes: assistant content shapes', () => { it('serializes a content-less, tool-call-less assistant message as "" content, never null', () => { // Aborted/empty assistant turns: no text, no calls → "". The earlier diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 5a2d330b7d..c0391b7bad 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,7 +4,7 @@ * @module dsh-llm-pi-ai/context */ -import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId, contentHasImage, LlmError, offloadRequestImages } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' @@ -26,82 +26,17 @@ function toolResultText(blocks: readonly ContentBlock[]): string { : block.type === 'tool-result' ? toolResultText(block.content) : '').join('') } -/** Model-facing stand-in for an image dropped to fit the request bound. */ -export const OFFLOADED_IMAGE_TEXT - = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]' - -/** Base64 length of `bytes` raw bytes (4 output characters per 3 input bytes, padded). */ -function base64Length(bytes: number): number { - return Math.ceil(bytes / 3) * 4 -} - -/** - * Select the images a request must drop to fit the per-request payload bound. - * History order is oldest-first, so the most recent images are omitted last. - * A single image larger than the bound is itself omitted. Locations use - * message and nested block indexes so JSON replay cannot change the result by - * splitting or preserving shared object identities. - * @param messages - complete request history, oldest first. - * @param maxRequestImageBytes - bound on total base64-encoded image payload; undefined leaves every image in place. - * @returns the image locations the conversion replaces with {@link OFFLOADED_IMAGE_TEXT}. - */ -function offloadedImages( - messages: readonly Message[], - maxRequestImageBytes: number | undefined, -): ReadonlySet { - const offloaded = new Set() - if (maxRequestImageBytes === undefined) return offloaded - const images: { location: string; base64Bytes: number }[] = [] - const collect = (messageIndex: number, blocks: readonly ContentBlock[], prefix: readonly number[] = []): void => { - for (const [blockIndex, block] of blocks.entries()) { - const path = [...prefix, blockIndex] - if (block.type === 'image') { - images.push({ - location: `${messageIndex}:${path.join('.')}`, - base64Bytes: base64Length(block.attachment.bytes), - }) - } else if (block.type === 'tool-result') { - collect(messageIndex, block.content, path) - } - } - } - for (const [messageIndex, message] of messages.entries()) collect(messageIndex, message.content) - let total = images.reduce((sum, image) => sum + image.base64Bytes, 0) - for (const image of images) { - if (total <= maxRequestImageBytes) break - offloaded.add(image.location) - total -= image.base64Bytes - } - return offloaded -} - -interface LocatedContentBlock { - readonly block: ContentBlock - readonly path: readonly number[] -} - -/** Attach stable nested indexes to blocks from one message. */ -function locatedBlocks(blocks: readonly ContentBlock[], prefix: readonly number[] = []): LocatedContentBlock[] { - return blocks.map((block, index) => ({ block, path: [...prefix, index] })) -} - async function userContent( - blocks: readonly LocatedContentBlock[], + blocks: readonly ContentBlock[], attachments: AttachmentStore, - offloaded: ReadonlySet, - messageIndex: number, ): Promise { const content: (TextContent | ImageContent)[] = [] - for (const { block, path } of blocks) { + for (const block of blocks) { switch (block.type) { case 'text': if (block.text.length > 0) content.push({ type: 'text', text: block.text }) break case 'image': { - if (offloaded.has(`${messageIndex}:${path.join('.')}`)) { - content.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT }) - break - } const stored = await attachments.readImage(block.attachment) content.push({ type: 'image', @@ -112,7 +47,7 @@ async function userContent( } case 'tool-result': { - const nested = await userContent(locatedBlocks(block.content, path), attachments, offloaded, messageIndex) + const nested = await userContent(block.content, attachments) if (typeof nested === 'string') { if (nested.length > 0) content.push({ type: 'text', text: nested }) } else { @@ -234,11 +169,11 @@ async function toPiContextWithImages( onReplayDegrade?: (reason: string) => void, maxRequestImageBytes?: number, ): Promise { - const offloaded = offloadedImages(options.messages, maxRequestImageBytes) + const requestMessages = offloadRequestImages(options.messages, maxRequestImageBytes) const toolNames = new Map() const messages: PiMessage[] = [] - for (const [messageIndex, message] of options.messages.entries()) { + for (const message of requestMessages) { if (message.role === 'system') { if (contentHasImage(message.content)) { throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') @@ -258,17 +193,16 @@ async function toPiContextWithImages( continue } // user role: text + tool results (each result becomes its own message). - const located = locatedBlocks(message.content) - const regular = located.filter(({ block }) => block.type !== 'tool-result') - const content = await userContent(regular, attachments, offloaded, messageIndex) - const results = located.filter((entry): entry is LocatedContentBlock & { block: Extract } => ( - entry.block.type === 'tool-result' + const regular = message.content.filter(block => block.type !== 'tool-result') + const content = await userContent(regular, attachments) + const results = message.content.filter((block): block is Extract => ( + block.type === 'tool-result' )) if (content.length > 0 || results.length === 0) { messages.push({ role: 'user', content, timestamp: 0 }) } - for (const { block: result, path } of results) { - const resultContent = await userContent(locatedBlocks(result.content, path), attachments, offloaded, messageIndex) + for (const result of results) { + const resultContent = await userContent(result.content, attachments) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index be41a12b07..2ab90d0c21 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { CallId, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { CallId, createMessage, createUserMessage, OFFLOADED_IMAGE_TEXT } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { OFFLOADED_IMAGE_TEXT, toPiContext } from '../src/context.ts' +import { toPiContext } from '../src/context.ts' import { toPiAssistant } from '../src/replay.ts' const ref: ImageAttachmentRef = { diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts index 19b760a02a..55c0719fb9 100644 --- a/packages/llm/llm/src/content.ts +++ b/packages/llm/llm/src/content.ts @@ -1,6 +1,11 @@ /** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */ import type { ContentBlock } from './types.ts' +import type { Message } from './message.ts' + +/** Model-facing stand-in for an image removed to fit a provider request bound. */ +export const OFFLOADED_IMAGE_TEXT + = '[image omitted to keep the request within its image limit; older images are omitted first. If this image is still needed, read its file again when a path is available; otherwise ask the user to attach it again.]' /** * True when typed model content contains an image block, walking nested @@ -14,3 +19,76 @@ export function contentHasImage(content: readonly ContentBlock[]): boolean { return content.some(block => block.type === 'image' || (block.type === 'tool-result' && contentHasImage(block.content))) } + +/** Base64 length of raw image bytes, including padding. */ +function base64Length(bytes: number): number { + return Math.ceil(bytes / 3) * 4 +} + +/** Collect base64 payload lengths in request and nested-block order. */ +function collectImageLengths(blocks: readonly ContentBlock[], lengths: number[]): void { + for (const block of blocks) { + if (block.type === 'image') { + lengths.push(base64Length(block.attachment.bytes)) + } else if (block.type === 'tool-result') { + collectImageLengths(block.content, lengths) + } + } +} + +/** Replace the first `remaining.count` image occurrences without mutating durable messages. */ +function replaceOldestImages( + blocks: readonly ContentBlock[], + remaining: { count: number }, +): ContentBlock[] { + let next: ContentBlock[] | undefined + for (const [index, block] of blocks.entries()) { + if (block.type === 'image' && remaining.count > 0) { + remaining.count -= 1 + next ??= blocks.slice(0, index) + next.push({ type: 'text', text: OFFLOADED_IMAGE_TEXT }) + continue + } + if (block.type === 'tool-result') { + const content = replaceOldestImages(block.content, remaining) + if (content !== block.content) { + next ??= blocks.slice(0, index) + next.push({ ...block, content }) + continue + } + } + next?.push(block) + } + return next ?? blocks as ContentBlock[] +} + +/** + * Return transient request messages whose oldest images are replaced until + * their accumulated base64 payload fits the configured bound. The selection + * is deterministic from durable message order and attachment metadata; a + * provider can serialize the returned messages without reading omitted bytes. + * @param messages - complete request history, oldest first. + * @param maxRequestImageBytes - positive bound on total base64 image payload; undefined preserves every image. + * @returns the original messages when they already fit, otherwise shallow message copies with replaced content trees. + */ +export function offloadRequestImages( + messages: readonly Message[], + maxRequestImageBytes: number | undefined, +): readonly Message[] { + if (maxRequestImageBytes === undefined) return messages + const lengths: number[] = [] + for (const message of messages) collectImageLengths(message.content, lengths) + let total = lengths.reduce((sum, bytes) => sum + bytes, 0) + let count = 0 + for (const bytes of lengths) { + if (total <= maxRequestImageBytes) break + total -= bytes + count += 1 + } + if (count === 0) return messages + const remaining = { count } + return messages.map((message) => { + const content = replaceOldestImages(message.content, remaining) + return content === message.content ? message : { ...message, content } + }) +} diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts new file mode 100644 index 0000000000..e61a585230 --- /dev/null +++ b/packages/llm/llm/tests/content.spec.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { CallId, createUserMessage, OFFLOADED_IMAGE_TEXT, offloadRequestImages } from '../src/index.ts' +import type { ContentBlock } from '../src/index.ts' + +const source = { kind: 'plugin' as const, plugin: 'test' } + +function image(bytes: number): ContentBlock { + return { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes, + width: 1, + height: 1, + }, + } +} + +describe('offloadRequestImages', () => { + it('preserves the original request when its base64 payload fits exactly', () => { + const messages = [createUserMessage({ content: [image(3), image(3)], source })] + expect(offloadRequestImages(messages, 8)).toBe(messages) + }) + + it('keeps five 3 MiB images at 20 MiB and offloads the oldest after one more raw byte', () => { + const rawImageBytes = 3 * 1024 * 1024 + const maxRequestImageBytes = 20 * 1024 * 1024 + const exact = [createUserMessage({ + content: Array.from({ length: 5 }, () => image(rawImageBytes)), + source, + })] + expect(offloadRequestImages(exact, maxRequestImageBytes)).toBe(exact) + + const over = [createUserMessage({ + content: [image(rawImageBytes + 1), ...Array.from({ length: 4 }, () => image(rawImageBytes))], + source, + })] + expect(offloadRequestImages(over, maxRequestImageBytes)[0]?.content).toEqual([ + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + ...Array.from({ length: 4 }, () => image(rawImageBytes)), + ]) + }) + + it('replaces the oldest nested occurrences without mutating durable messages', () => { + const shared = image(3) + const messages = [ + createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('shot'), + content: [shared], + }], + source, + }), + createUserMessage({ content: [shared, image(3)], source }), + ] + + const fitted = offloadRequestImages(messages, 8) + expect(fitted).not.toBe(messages) + expect(fitted[0]?.content).toEqual([{ + type: 'tool-result', + toolCallId: CallId('shot'), + content: [{ type: 'text', text: OFFLOADED_IMAGE_TEXT }], + }]) + expect(fitted[1]?.content).toEqual([shared, image(3)]) + expect(messages[0]?.content[0]).toMatchObject({ type: 'tool-result', content: [shared] }) + }) + + it('replaces a single image that cannot fit', () => { + const messages = [createUserMessage({ content: [image(300)], source })] + expect(offloadRequestImages(messages, 8)[0]?.content) + .toEqual([{ type: 'text', text: OFFLOADED_IMAGE_TEXT }]) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96bfb5d0de..235eb47887 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5313,6 +5313,9 @@ importers: '@deepseek-ai/dsh-anonymous-user-id': specifier: workspace:^ version: link:../../identity/anonymous-user-id + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials From 036ba74c43a59945c5d89487dcfe26d0f8f136fb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 19 Aug 2026 11:41:49 +0800 Subject: [PATCH 106/137] test(fs-local): attribute diff-basis cancellation to fsio allocations The observes-cancellation-after-open/stat test asserted that the process-wide Buffer.allocUnsafe call count stayed flat after abort, but vitest's fork IPC (node:internal/child_process serialization) also calls Buffer.allocUnsafe, so unrelated IPC traffic made the assertion timing-racy under CI load. Attribute each allocation to the fsio read path by stack and assert that the abort prevents the diff-basis buffer allocation. --- packages/fs/fs-local/tests/fsio.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/fs/fs-local/tests/fsio.spec.ts b/packages/fs/fs-local/tests/fsio.spec.ts index b51f6ba83b..5cf5ffd5d3 100644 --- a/packages/fs/fs-local/tests/fsio.spec.ts +++ b/packages/fs/fs-local/tests/fsio.spec.ts @@ -475,7 +475,17 @@ describe('readTextForDiff', () => { const reached = Promise.withResolvers() const release = Promise.withResolvers() let statCalls = 0 - const allocate = vi.spyOn(Buffer, 'allocUnsafe') + const allocUnsafe = Buffer.allocUnsafe.bind(Buffer) + // Buffer.allocUnsafe is also called by vitest's fork IPC + // (node:internal/child_process serialization), so a process-wide call count + // is timing-racy under CI load. Attribute allocations to the fsio read path + // instead: the abort must prevent the diff-basis buffer allocation. + const fsioAllocations: string[] = [] + const allocate = vi.spyOn(Buffer, 'allocUnsafe').mockImplementation((size: number) => { + const stack = new Error().stack ?? '' + if (stack.includes('readTextForDiff')) fsioAllocations.push(stack) + return allocUnsafe(size) + }) vi.resetModules() vi.doMock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal() @@ -509,12 +519,11 @@ describe('readTextForDiff', () => { const controller = new AbortController() const pending = isolatedReadTextForDiff(file, 8, controller.signal) await reached.promise - const allocationCalls = allocate.mock.calls.length controller.abort() release.resolve(undefined) await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' }) expect(statCalls).toBe(stage === 'open' ? 0 : 1) - expect(allocate).toHaveBeenCalledTimes(allocationCalls) + expect(fsioAllocations).toEqual([]) } finally { release.resolve(undefined) allocate.mockRestore() From cf4a27c47143564e5951e66233f1dc35b7a39c60 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 19 Aug 2026 11:48:01 +0800 Subject: [PATCH 107/137] fix(llm-pi-ai): refuse valueless compat and group gates by compat type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two live defects in the compat surface. A valueless key (`supportsDeveloperRole:`) survives schemastery as null, and resolution carried it forward as a configured value. It landed on `Model.compat` as null, which replaced the installed catalog entry's value and left pi-ai's `??` reaching for its baseURL detection — the catalog layer skipped entirely, and the switch written but not applied. The vocabulary check now refuses it where it is written, matching the `reasoningEfforts` precedent in the same file. The gates were keyed by protocol name, but pi-ai keys compat by type: `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` share one `OpenAIResponsesCompat`, so two shipped catalog routes were refused the fields their own models declare. Gates now group by compat type, `bedrock-converse-stream` gains its own, and the protocol set is derived from `Model.compat`'s conditional so a release that gives a further protocol a compat type fails the gate list by name. Field types are derived from upstream rather than restated, with a proof pinning the profile assignable to the upstream types, so a widened value union cannot silently narrow what configuration accepts. The `undefined` filter stays removed: `exactOptionalPropertyTypes` keeps a typed caller from writing one, and schemastery never materializes one, so it was validation for a value the static interface already excludes. Refs #2646 --- ...-08-18-pi-ai-wire-compat-surface.i18n.yaml | 4 +- .../2026-08-18-pi-ai-wire-compat-surface.md | 9 +- ...2026-08-18-pi-ai-wire-compat-surface.zh.md | 9 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 18 ++- docs/config-catalog.zh.md | 18 ++- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/catalog.ts | 129 ++++++++++++++++-- packages/llm/llm-pi-ai/src/config.ts | 16 ++- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 38 ++++++ 12 files changed, 212 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml index 0a36513613..9a9f1614fd 100644 --- a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.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-18-pi-ai-wire-compat-surface.md -2026-08-18-pi-ai-wire-compat-surface.md: c7e0bc75806e8ba022db9c9f17cfe2b621c21611 -2026-08-18-pi-ai-wire-compat-surface.zh.md: c592044e239421110813feb364bacc142a9d2d32 +2026-08-18-pi-ai-wire-compat-surface.md: 3da2db1ebdf67bcfaf8c872491356b0ef7d0ca89 +2026-08-18-pi-ai-wire-compat-surface.zh.md: ff9870f5863fb96ee026dfab1b96b4e1f3e6e238 diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md index c7e0bc7580..3da2db1ebd 100644 --- a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.md @@ -14,13 +14,13 @@ Writing the field anyway was worse than unsupported. schemastery passes unknown ## Decision -Three drift gates — one per pi-ai compat type, keyed `Record` — classify all thirty upstream fields as `offer` or `withhold`. Twenty are offered. The line is what a private URL can imply: a deployment must be able to state what nothing can infer from an unrecognized endpoint, while a field pi-ai's installed catalog sets for a named vendor stays withheld, because a route reaching for `openRouterRouting` or `deferredToolsMode` is a catalog route that should be named as such and inherit the value. +One drift gate per pi-ai compat type — keyed `Record` — classifies every upstream field as `offer` or `withhold`. Thirty distinct fields, twenty offered. The line is what a private URL can imply: a deployment must be able to state what nothing can infer from an unrecognized endpoint, while a field pi-ai's installed catalog sets for a named vendor stays withheld, because a route reaching for `openRouterRouting` or `deferredToolsMode` is a catalog route that should be named as such and inherit the value. -`PiAiCompatProfile` stays an explicit interface with per-field JSDoc — it is what a configuration surface renders and what `docs/config-catalog.md` pastes — and a type-level `AssertNever` over the symmetric difference proves it names exactly the offered set. The schemastery schema is declared `z`, so the four faces lock together: an upstream field added, a gate entry missing, an interface field forgotten, or a schema key omitted each fails compilation naming the field. +`PiAiCompatProfile` stays an explicit interface with per-field JSDoc — it is what a configuration surface renders and what `docs/config-catalog.md` pastes — and a type-level `AssertNever` over the symmetric difference proves it names exactly the offered set. The schemastery schema is declared `z`, and `exactOptionalPropertyTypes` is what makes that annotation load-bearing in both directions, so the four faces lock together: an upstream field added, a gate entry missing, an interface field forgotten, or a schema key omitted each fails compilation. Field *types* are derived from upstream rather than restated, and a second proof pins the profile assignable to the upstream compat types, so a widened value union cannot silently narrow what configuration accepts — the cast to `ModelCompat` at materialization would otherwise hide it. -Protocol applicability is per field rather than per block. `supportsDeveloperRole` is settable wherever pi-ai declares it (`openai-completions` and `openai-responses`), `thinkingFormat` only on the former, `supportsTemperature` only on `anthropic-messages`. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it. `chatTemplateKwargs` is offered, which is what makes the two `chat-template` thinking formats nameable. +Protocol applicability is per field, and grouping follows the compat *type* rather than the protocol name: pi-ai gives `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` one `OpenAIResponsesCompat`, so a switch settable on one is settable on all three. Keying by protocol name alone refused two shipped catalog routes the fields their own models declare. The protocol set is derived from `Model.compat`'s own conditional, so a release that gives a further protocol a compat type fails the gate list by name. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it. `chatTemplateKwargs` is offered, which is what makes the two `chat-template` thinking formats nameable; nothing cross-checks that pairing, because the format in force may come from the catalog entry or from pi-ai's detection, neither of which resolution can read. -A `compat` key no protocol declares, and one a gate withholds, are both refused where they are written rather than dropped. The check runs over every key before any protocol resolves, so a misspelling fails even on a route whose models never reach the protocol that would have taken it. It reads raw keys deliberately: a withheld or undeclared name is absent from the schema, so schemastery cannot have materialized it and a person wrote it. Fields carrying a value are then filtered separately, because schemastery materializes an absent dict as `{}` and `chatTemplateKwargs` is present on every parsed profile whether or not anyone wrote one. +Three kinds of `compat` key are refused where they are written rather than dropped: one no protocol declares, one a gate withholds, and one written with no value. The check runs over every key before any protocol resolves, so a misspelling fails even on a route whose models never reach the protocol that would have taken it. It reads raw keys deliberately: a withheld or undeclared name is absent from the schema, so schemastery cannot have materialized it and a person wrote it. The valueless case is the one that has to fail rather than be ignored — schemastery passes a YAML bare key through as null, and carrying it forward writes null over the installed catalog's value, leaving pi-ai's `??` reaching for its baseURL detection with the catalog layer skipped entirely. Fields carrying a value are then filtered separately, because schemastery materializes an absent dict as `{}` and `chatTemplateKwargs` is present on every parsed profile whether or not anyone wrote one. ## Where a refusal lands @@ -47,5 +47,6 @@ An external edit to the settings file is the one path that cannot report: the pr - An OpenAI-compatible gateway that rejects the `developer` role, `max_completion_tokens`, `store`, `stream_options`, or `strict` is now configuration rather than an unreachable provider, and the same holds for an Anthropic-compatible gateway rejecting `temperature` or tool `cache_control`. - A pi-ai upgrade that adds a compat field fails the build until someone classifies it, which is how `chatTemplateKwargs` and the `chat-template` formats stopped being a standing exception. - Unknown compat keys join every other configuration error's failure model. The improvement over the previous silent drop is bounded by the settings seam: an external file edit still keeps its last good value and warns, so the operator's signal is a restart rather than the write. +- **Deferred, not closed:** a route that repoints `api` and configures no compat at all keeps the installed entry's `compat` through the model literal's `...base` spread, in the *other* protocol's shape. Fields several compat types share (`supportsLongCacheRetention`, `sendSessionAffinityHeaders`) therefore cross protocols. It predates this surface — the early return it rides existed before — and is left for its own change. - **Deferred, not closed:** `publish()` reports a rejected stored section only through `ctx.logger.warn`, with no user-visible channel. It affects every settings namespace and is owned by `dsh-settings`. - [[2026-08-08-pi-ai-per-model-reasoning-declarations]] is partially superseded: its compat-scope statements are restated here, while its `reasoningEfforts` shape, the alternatives that shape beat, and `modelOverrides` remain the current authority. diff --git a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md index c592044e23..ff9870f586 100644 --- a/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-pi-ai-wire-compat-surface.zh.md @@ -14,13 +14,13 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提 ## Decision -三张漂移门禁——每个 pi-ai compat 类型一张,以 `Record` 为键——把全部三十个上游字段分类为 `offer` 或 `withhold`,其中二十个开放。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。 +每个 pi-ai compat 类型一张漂移门禁——以 `Record` 为键——把每一个上游字段分类为 `offer` 或 `withhold`。去重后三十个字段,开放二十个。分界线在于私有 URL 能推出什么:凡是无法从未识别端点推断的,部署方必须能够说出口;而 pi-ai 已安装 catalog 为具名厂商设定的字段保持扣留,因为伸手去够 `openRouterRouting` 或 `deferredToolsMode` 的路由,本就是一条应当以该厂商命名、并继承其值的 catalog 路由。 -`PiAiCompatProfile` 保持为带逐字段 JSDoc 的显式 interface——它是配置界面所渲染、也是 `docs/config-catalog.md` 所粘贴的东西——并由一个作用在对称差上的类型级 `AssertNever` 证明它恰好命名了开放集。schemastery schema 声明为 `z`,于是四个面互锁:上游新增字段、门禁漏一条、interface 忘记一个字段、schema 少一个键,都会在编译期以点名该字段的方式失败。 +`PiAiCompatProfile` 保持为带逐字段 JSDoc 的显式 interface——它是配置界面所渲染、也是 `docs/config-catalog.md` 所粘贴的东西——并由一个作用在对称差上的类型级 `AssertNever` 证明它恰好命名了开放集。schemastery schema 声明为 `z`,而使这条标注在两个方向上都真正吃劲的是 `exactOptionalPropertyTypes`,于是四个面互锁:上游新增字段、门禁漏一条、interface 忘记一个字段、schema 少一个键,都会在编译期失败。字段的**类型**派生自上游而非重述,另有一条证明把 profile 钉为可赋值给上游 compat 类型,因此被拓宽的值并集不会悄悄收窄配置所接受的范围——否则物化处对 `ModelCompat` 的强转会把它洗掉。 -协议适用性逐字段判断,而非整块判断。`supportsDeveloperRole` 在 pi-ai 声明它的任何地方均可设置(`openai-completions` 与 `openai-responses`),`thinkingFormat` 只在前者,`supportsTemperature` 只在 `anthropic-messages`。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 予以开放,这正是两个 `chat-template` 思考格式得以命名的前提。 +协议适用性逐字段判断,且归组依据是 compat **类型**而非协议名:pi-ai 让 `openai-responses`、`azure-openai-responses` 与 `openai-codex-responses` 共用同一个 `OpenAIResponsesCompat`,因此可设在其中之一的开关,三者皆可设。仅按协议名归组曾使两条随附的 catalog 路由拿不到其自身模型所声明的字段。协议集派生自 `Model.compat` 自身的条件类型,因此某个版本若给别的协议加上 compat 类型,门禁列表会以点名的方式失败。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。`chatTemplateKwargs` 予以开放,这正是两个 `chat-template` 思考格式得以命名的前提;两者的配对不做交叉校验,因为实际生效的格式可能来自 catalog 条目或 pi-ai 的检测,而解析读不到那两层。 -没有任何协议声明的 `compat` 键,以及被门禁扣留的键,都在其被写下之处遭到拒绝而非丢弃。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,`chatTemplateKwargs` 都出现在每一个解析过的 profile 上。 +三类 `compat` 键在其被写下之处遭到拒绝而非丢弃:没有任何协议声明的键、被门禁扣留的键,以及完全没有写值的键。该检查在任何协议解析之前遍历全部键,因此即便路由上的模型永远不会走到那个本会接受它的协议,笔误同样失败。它刻意读取原始键:被扣留或未声明的名字不在 schema 中,所以 schemastery 不可能物化它,写下它的必然是人。无值那一类是必须失败而不能忽略的:schemastery 会把 YAML 裸键放行为 null,照单收下就会用 null 写覆盖已安装 catalog 的值,随后 pi-ai 的 `??` 转而去够它的 baseURL 检测,catalog 这一层被整个跳过。随后再单独过滤携带值的字段,因为 schemastery 会把缺省的 dict 物化成 `{}`,于是无论有没有人写过,`chatTemplateKwargs` 都出现在每一个解析过的 profile 上。 ## Where a refusal lands @@ -47,5 +47,6 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状——系统提 - 拒绝 `developer` 角色、`max_completion_tokens`、`store`、`stream_options` 或 `strict` 的 OpenAI 兼容网关,如今属于配置问题而非无法接入的提供方;拒绝 `temperature` 或工具 `cache_control` 的 Anthropic 兼容网关同理。 - pi-ai 升级新增 compat 字段会使构建失败,直到有人为它做出分类——`chatTemplateKwargs` 与那两个 `chat-template` 格式正是因此不再是一项长期例外。 - 未知 compat 键并入了其余所有配置错误的失败模型。相对此前静默丢弃的改善程度受 settings seam 限制:外部文件编辑仍会保留其上一个有效值并告警,因此运维拿到的信号是一次重启,而不是那次写入。 +- **搁置而非解决:** 改指 `api` 且完全未配置 compat 的路由,会经模型字面量的 `...base` 展开保留已安装条目的 `compat`,且形状属于**另一个**协议。多个 compat 类型共有的字段(`supportsLongCacheRetention`、`sendSessionAffinityHeaders`)因而会跨协议串味。它早于本面存在——其所依附的提前返回本就在那里——留给独立的一次改动处理。 - **搁置而非解决:** `publish()` 对被拒绝的已存 section 只通过 `ctx.logger.warn` 报告,没有面向用户的通道。它影响每一个 settings namespace,归属 `dsh-settings`。 - [[2026-08-08-pi-ai-per-model-reasoning-declarations]] 被部分取代:其 compat 作用域的陈述在此重述,而其 `reasoningEfforts` 形状、该形状所击败的备选方案以及 `modelOverrides` 仍是当前权威。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7b71e2324c..8b02a34c27 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: 35855f511f5c574bfad46aa9aac901a1c151f868 -config-catalog.zh.md: 1cd5fc5d7af19b414a6259783cd0a7c7a18990d6 +config-catalog.md: e5a3dc53ceb0e43c6c758d94222327f2bd4b570f +config-catalog.zh.md: 2f0fb425de113ef88b5e4c809966a449ba44f674 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 35855f511f..e5a3dc53ce 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1074,7 +1074,7 @@ export interface PiAiCompatProfile { /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ supportsUsageInStreaming?: boolean /** Which output-cap field the endpoint reads; `openai-completions`. */ - maxTokensField?: 'max_completion_tokens' | 'max_tokens' + maxTokensField?: NonNullable /** Whether tool results must carry `name`; `openai-completions`. */ requiresToolResultName?: boolean /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ @@ -1085,12 +1085,18 @@ export interface PiAiCompatProfile { requiresReasoningContentOnAssistantMessages?: boolean /** Reasoning parameter format the endpoint expects; `openai-completions`. */ thinkingFormat?: PiAiThinkingFormat - /** Kwargs sent as `chat_template_kwargs`, for the two `chat-template` thinking formats; `openai-completions`. */ - chatTemplateKwargs?: Record + /** + * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the + * two `chat-template` thinking formats; `openai-completions`. Nothing checks + * that pairing: the format in force may come from the installed catalog + * entry or from pi-ai's own baseURL detection, neither of which resolution + * can read, so kwargs set beside another format are sent nowhere. + */ + chatTemplateKwargs?: NonNullable /** Whether the endpoint accepts `strict` in tool definitions; `openai-completions`, `openai-responses`. */ supportsStrictMode?: boolean /** Prompt-cache marker convention; `openai-completions`. */ - cacheControlFormat?: 'anthropic' + cacheControlFormat?: NonNullable /** Whether the endpoint accepts long prompt-cache retention; all three protocols. */ supportsLongCacheRetention?: boolean /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ @@ -1124,9 +1130,9 @@ export type PiAiReasoningEfforts = Partial ``` -Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `ChatTemplateKwargValue` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) +Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`) -Source: [`packages/llm/llm-pi-ai/src/config.ts:193`](../packages/llm/llm-pi-ai/src/config.ts) +Source: [`packages/llm/llm-pi-ai/src/config.ts:201`](../packages/llm/llm-pi-ai/src/config.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 1cd5fc5d7a..2f0fb425de 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1076,7 +1076,7 @@ export interface PiAiCompatProfile { /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ supportsUsageInStreaming?: boolean /** Which output-cap field the endpoint reads; `openai-completions`. */ - maxTokensField?: 'max_completion_tokens' | 'max_tokens' + maxTokensField?: NonNullable /** Whether tool results must carry `name`; `openai-completions`. */ requiresToolResultName?: boolean /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ @@ -1087,12 +1087,18 @@ export interface PiAiCompatProfile { requiresReasoningContentOnAssistantMessages?: boolean /** Reasoning parameter format the endpoint expects; `openai-completions`. */ thinkingFormat?: PiAiThinkingFormat - /** Kwargs sent as `chat_template_kwargs`, for the two `chat-template` thinking formats; `openai-completions`. */ - chatTemplateKwargs?: Record + /** + * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the + * two `chat-template` thinking formats; `openai-completions`. Nothing checks + * that pairing: the format in force may come from the installed catalog + * entry or from pi-ai's own baseURL detection, neither of which resolution + * can read, so kwargs set beside another format are sent nowhere. + */ + chatTemplateKwargs?: NonNullable /** Whether the endpoint accepts `strict` in tool definitions; `openai-completions`, `openai-responses`. */ supportsStrictMode?: boolean /** Prompt-cache marker convention; `openai-completions`. */ - cacheControlFormat?: 'anthropic' + cacheControlFormat?: NonNullable /** Whether the endpoint accepts long prompt-cache retention; all three protocols. */ supportsLongCacheRetention?: boolean /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ @@ -1126,9 +1132,9 @@ export type PiAiReasoningEfforts = Partial ``` -依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `ChatTemplateKwargValue`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) +依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`) -来源:[`packages/llm/llm-pi-ai/src/config.ts:193`](../packages/llm/llm-pi-ai/src/config.ts) +来源:[`packages/llm/llm-pi-ai/src/config.ts:201`](../packages/llm/llm-pi-ai/src/config.ts) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 39451c8f11..ceb967ad00 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 8d4e52f739fc767608eed2fdd1c91bfb09a99023 -README.zh.md: a547047802080c5ec03d606e3b6841302cbc78fc +README.md: 670e349e06df4dc5c3538363f6b908c1974dca6f +README.zh.md: fc60720b0757d8288adc8a559a964bcc03f073d2 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 8d4e52f739..670e349e06 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -92,7 +92,9 @@ The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` wit pi-ai shapes each request from the provider id and baseURL: which role carries the system prompt, which field caps output, how a thinking level travels. A private gateway's URL says nothing, and for an endpoint pi-ai does not recognize the detection answers as though it were OpenAI itself — a reasoning model's system prompt goes out as `developer`, the output cap as `max_completion_tokens`, the thinking level as a bare `reasoning_effort` — and most OpenAI-compatible gateways reject at least one of those. `compat` is therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's own detection; a route-level switch shadows the catalog entry's value for every model that reads it, and there is no spelling for handing a field back to the catalog short of restating its value. -Each switch belongs to the protocols whose pi-ai compat type declares it: `supportsDeveloperRole` is settable on an `openai-completions` or `openai-responses` route, `thinkingFormat` only on the former, `supportsTemperature` only on `anthropic-messages`. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it at all. Two kinds of key are refused rather than dropped: one no protocol declares (a misspelling), and one pi-ai's installed catalog owns for a named vendor (`openRouterRouting`, `zaiToolStream`, `deferredToolsMode`, `sessionAffinityFormat`, `supportsOpenAIGrammarTools`, `supportsToolSearch`, `supportsExplicitPromptCacheMode`, `supportsToolReferences`, `vercelGatewayRouting`, `sendSessionAffinityHeaders`) — a route needing a vendor's own switch is a catalog route that should be named as such. The offered set is pinned to pi-ai's three compat types by drift gates, so an upgrade adding a field fails the build until someone classifies it. +Each switch belongs to the protocols whose pi-ai compat type declares it, and grouping follows the compat *type* rather than the protocol name: the three Responses protocols (`openai-responses`, `azure-openai-responses`, `openai-codex-responses`) share one compat type, so a switch settable on one is settable on all three. `supportsDeveloperRole` is settable on `openai-completions` and on those three; `thinkingFormat` only on `openai-completions`; `supportsTemperature` only on `anthropic-messages`; `supportsStrictMode` also reaches `bedrock-converse-stream`. A model-level switch its protocol does not take fails resolution naming what that protocol does offer; a route-level one lands on the models that read it and skips the rest, and is refused only when no model on the route could read it at all. + +Three kinds of key are refused rather than dropped: one no protocol declares (a misspelling), one pi-ai's installed catalog owns for a named vendor (`openRouterRouting`, `zaiToolStream`, `deferredToolsMode`, `sessionAffinityFormat`, `supportsOpenAIGrammarTools`, `supportsToolSearch`, `supportsExplicitPromptCacheMode`, `supportsToolReferences`, `vercelGatewayRouting`, `sendSessionAffinityHeaders`) — a route needing a vendor's own switch is a catalog route that should be named as such — and one written with no value at all (`supportsDeveloperRole:`), which schemastery passes through as null and which would otherwise replace the installed catalog's value with nothing. The offered set is pinned to pi-ai's four compat types by drift gates, the protocols carrying them are derived from `Model.compat` itself, and each field's type is derived from upstream rather than restated, so an upgrade that adds a field, gives a further protocol a compat type, or widens a value union fails the build until someone classifies it. A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index a547047802..fc60720b07 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -92,7 +92,9 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩 pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输。私有网关的 URL 什么也说明不了,而对于 pi-ai 无法识别的端点,其检测会当作 OpenAI 本身来回答——推理模型的系统提示词以 `developer` 发出、输出上限写作 `max_completion_tokens`、思考级别只发一个裸的 `reasoning_effort`——而多数 OpenAI 兼容网关至少会拒绝其中之一。因此 `compat` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 自身的检测;路由级开关会为每个读取它的模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。 -每个开关归属于其 pi-ai compat 类型声明了它的那些协议:`supportsDeveloperRole` 可设在 `openai-completions` 或 `openai-responses` 路由上,`thinkingFormat` 只能设在前者,`supportsTemperature` 只能设在 `anthropic-messages` 上。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。两类键会被拒绝而非丢弃:没有任何协议声明的键(笔误),以及 pi-ai 已安装 catalog 为具名厂商掌管的键(`openRouterRouting`、`zaiToolStream`、`deferredToolsMode`、`sessionAffinityFormat`、`supportsOpenAIGrammarTools`、`supportsToolSearch`、`supportsExplicitPromptCacheMode`、`supportsToolReferences`、`vercelGatewayRouting`、`sendSessionAffinityHeaders`)——需要某厂商专属开关的路由,本就是一条应当以该厂商命名的 catalog 路由。开放集由漂移门禁钉在 pi-ai 的三个 compat 类型上,因此上游新增字段会使构建失败,直到有人为它做出分类。 +每个开关归属于其 pi-ai compat 类型声明了它的那些协议,且归组依据是 compat **类型**而非协议名:三个 Responses 协议(`openai-responses`、`azure-openai-responses`、`openai-codex-responses`)共用同一个 compat 类型,因此可设在其中之一的开关,三者皆可设。`supportsDeveloperRole` 可设在 `openai-completions` 与这三者上;`thinkingFormat` 只能设在 `openai-completions`;`supportsTemperature` 只能设在 `anthropic-messages`;`supportsStrictMode` 还可达 `bedrock-converse-stream`。模型级开关若其协议并不接受,解析失败并点名该协议实际提供哪些开关;路由级开关则落在读取它的模型上、跳过其余模型,只有当路由上没有任何模型能读取它时才被拒绝。 + +三类键会被拒绝而非丢弃:没有任何协议声明的键(笔误);pi-ai 已安装 catalog 为具名厂商掌管的键(`openRouterRouting`、`zaiToolStream`、`deferredToolsMode`、`sessionAffinityFormat`、`supportsOpenAIGrammarTools`、`supportsToolSearch`、`supportsExplicitPromptCacheMode`、`supportsToolReferences`、`vercelGatewayRouting`、`sendSessionAffinityHeaders`)——需要某厂商专属开关的路由,本就是一条应当以该厂商命名的 catalog 路由;以及完全没有写值的键(`supportsDeveloperRole:`),schemastery 会把它放行为 null,若照单收下就会用空值替换已安装 catalog 的值。开放集由漂移门禁钉在 pi-ai 的四个 compat 类型上,承载它们的协议集派生自 `Model.compat` 本身,每个字段的类型也派生自上游而非重述,因此上游新增字段、给别的协议加上 compat 类型、或拓宽某个值并集,都会使构建失败,直到有人为它做出分类。 条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成单次请求上限。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index c945800bea..9b49fdda87 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -17,7 +17,9 @@ import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' import type { AnthropicMessagesCompat, Api, + BedrockCompat, ChatTemplateKwargValue, + KnownApi, Model, ModelCost, ModelThinkingLevel, @@ -109,6 +111,41 @@ const THINKING_FORMAT_GATE: Record = { /** Reasoning-dispatch wire formats a profile may name, most-reached first. */ export const SUPPORTED_THINKING_FORMATS = Object.keys(THINKING_FORMAT_GATE) as readonly PiAiThinkingFormat[] +/** The output-cap field spellings pi-ai accepts. */ +export type PiAiMaxTokensField = NonNullable + +/** Drift gate over {@link PiAiMaxTokensField}; an upstream spelling added here fails compilation until named. */ +const MAX_TOKENS_FIELD_GATE: Record = { + max_completion_tokens: true, + max_tokens: true, +} + +/** The output-cap field spellings a profile may name. */ +export const MAX_TOKENS_FIELDS = Object.keys(MAX_TOKENS_FIELD_GATE) as readonly PiAiMaxTokensField[] + +/** The prompt-cache marker conventions pi-ai accepts. */ +export type PiAiCacheControlFormat = NonNullable + +/** Drift gate over {@link PiAiCacheControlFormat}; a new upstream convention fails compilation until named. */ +const CACHE_CONTROL_FORMAT_GATE: Record = { + anthropic: true, +} + +/** The prompt-cache marker conventions a profile may name. */ +export const CACHE_CONTROL_FORMATS = Object.keys(CACHE_CONTROL_FORMAT_GATE) as readonly PiAiCacheControlFormat[] + +/** The request-state placeholders a `chat_template_kwargs` value may name. */ +export type PiAiChatTemplateVar = Extract['$var'] + +/** Drift gate over {@link PiAiChatTemplateVar}; a new upstream placeholder fails compilation until named. */ +const CHAT_TEMPLATE_VAR_GATE: Record = { + 'thinking.enabled': true, + 'thinking.effort': true, +} + +/** The request-state placeholders a profile may name. */ +export const CHAT_TEMPLATE_VARS = Object.keys(CHAT_TEMPLATE_VAR_GATE) as readonly PiAiChatTemplateVar[] + let providerIndex: Map | undefined /** @@ -243,16 +280,47 @@ const ANTHROPIC_COMPAT_GATE = { supportsToolReferences: 'withhold', } as const satisfies Record +/** Disposition of every `BedrockCompat` field; a drift gate like the one above. */ +const BEDROCK_COMPAT_GATE = { + supportsStrictMode: 'offer', +} as const satisfies Record + /** - * The compat gate of every wire protocol a profile may configure, in the - * protocol table's order. A protocol absent here takes no configured compat, - * which is why an unrecognized `api` refuses every switch rather than - * silently dropping it. + * Every wire protocol pi-ai gives a compat type. Derived from `Model.compat`'s + * own conditional rather than listed by hand, so a pi-ai release that gives a + * further protocol a compat type fails the {@link COMPAT_GATES} entry list + * until someone classifies its fields. A protocol pi-ai gives no compat type + * resolves away here and takes no configured compat at all. */ -const COMPAT_GATES: Readonly>>> = { +type ApiWithCompat = { [K in KnownApi]: NonNullable['compat']> extends never ? never : K }[KnownApi] + +/** + * The compat gate of every wire protocol a profile may configure. + * + * Keyed by protocol, but grouped by pi-ai's compat *type*: the three Responses + * protocols share `OpenAIResponsesCompat`, so a switch settable on one is + * settable on all three. Keying by protocol alone would refuse + * `azure-openai-responses` and `openai-codex-responses` the fields their own + * models declare. + */ +const COMPAT_GATES: Readonly>>> = { 'openai-completions': COMPLETIONS_COMPAT_GATE, 'openai-responses': RESPONSES_COMPAT_GATE, + 'azure-openai-responses': RESPONSES_COMPAT_GATE, + 'openai-codex-responses': RESPONSES_COMPAT_GATE, 'anthropic-messages': ANTHROPIC_COMPAT_GATE, + 'bedrock-converse-stream': BEDROCK_COMPAT_GATE, +} + +/** + * The compat gate of one resolved protocol. A `string` lookup rather than a + * keyed read: a route's `api` is configuration, so it may name a protocol + * pi-ai gives no compat type — or none at all. + * @param api - resolved wire protocol. + * @returns that protocol's field gate, or `undefined` when it takes no compat. + */ +function compatGate(api: string): Readonly> | undefined { + return (COMPAT_GATES as Readonly>>>)[api] } /** The field names one gate offers. */ @@ -263,6 +331,7 @@ type OfferedCompatField = | OfferedIn | OfferedIn | OfferedIn + | OfferedIn /** * pi-ai wire-compatibility switches, set on the route (its models' default) or @@ -293,7 +362,7 @@ export interface PiAiCompatProfile { /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */ supportsUsageInStreaming?: boolean /** Which output-cap field the endpoint reads; `openai-completions`. */ - maxTokensField?: 'max_completion_tokens' | 'max_tokens' + maxTokensField?: NonNullable /** Whether tool results must carry `name`; `openai-completions`. */ requiresToolResultName?: boolean /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */ @@ -304,12 +373,18 @@ export interface PiAiCompatProfile { requiresReasoningContentOnAssistantMessages?: boolean /** Reasoning parameter format the endpoint expects; `openai-completions`. */ thinkingFormat?: PiAiThinkingFormat - /** Kwargs sent as `chat_template_kwargs`, for the two `chat-template` thinking formats; `openai-completions`. */ - chatTemplateKwargs?: Record + /** + * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the + * two `chat-template` thinking formats; `openai-completions`. Nothing checks + * that pairing: the format in force may come from the installed catalog + * entry or from pi-ai's own baseURL detection, neither of which resolution + * can read, so kwargs set beside another format are sent nowhere. + */ + chatTemplateKwargs?: NonNullable /** Whether the endpoint accepts `strict` in tool definitions; `openai-completions`, `openai-responses`. */ supportsStrictMode?: boolean /** Prompt-cache marker convention; `openai-completions`. */ - cacheControlFormat?: 'anthropic' + cacheControlFormat?: NonNullable /** Whether the endpoint accepts long prompt-cache retention; all three protocols. */ supportsLongCacheRetention?: boolean /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */ @@ -342,6 +417,22 @@ export type EveryProfileFieldIsOffered = AssertNever> +/** Compile-time constraint that `T` is `true`. */ +type AssertTrue = T + +/** Every compat type a gate classifies, merged so one `Pick` reaches all offered fields. */ +type UpstreamCompat = OpenAICompletionsCompat & OpenAIResponsesCompat & AnthropicMessagesCompat & BedrockCompat + +/** + * Proof that each documented field carries its upstream type, not a hand-copied + * restatement of it. The name gates above pin *which* fields exist; without + * this, a literal union narrower than pi-ai's would refuse a value the provider + * accepts, and `resolveModelCompat`'s cast to `ModelCompat` would hide it. + */ +export type EveryProfileFieldMatchesUpstream = AssertTrue< + PiAiCompatProfile extends Partial> ? true : false +> + /** * The compat entries a profile actually set. * @@ -350,7 +441,9 @@ export type EveryOfferedFieldIsDocumented = AssertNever disposition === 'offer' ? [field] : []) + return Object.entries(compatGate(api) ?? {}).flatMap(([field, disposition]) => disposition === 'offer' ? [field] : []) } /** @@ -415,7 +508,17 @@ function assertOfferedCompatFields( // Every key, not only the ones carrying a value: a withheld or undeclared // name is never in the schema, so schemastery cannot have materialized it — // whatever its value, a person wrote it and expects it to do something. - for (const field of Object.keys(compat ?? {})) { + for (const [field, value] of Object.entries(compat ?? {})) { + // A valueless key (`supportsDeveloperRole:`) survives schemastery, which + // passes nullable data through before any member schema runs — the same + // behavior `reasoningEfforts` documents. Carrying it forward would write + // null over the installed catalog's value and leave pi-ai's `??` reaching + // for its baseURL detection, which is the "written but not applied" + // outcome this surface exists to refuse. + if (value === null) { + invalid(provider, `${site} sets compat "${field}" with no value; give it one, or remove the key to` + + ' keep the installed catalog\'s value') + } if (compatProtocols(field).length > 0) continue const declared = Object.values(COMPAT_GATES).some(gate => gate[field] !== undefined) if (declared) { @@ -625,7 +728,7 @@ function resolveModelCompat( base: Model | undefined, api: string, ): { compat: ModelCompat } | Record { - const gate = COMPAT_GATES[api] + const gate = compatGate(api) const configured: Record = {} for (const [field, value] of configuredCompatEntries(route)) { if (gate?.[field] !== 'offer') continue diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index b4bd37ad9c..a95af39fea 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -21,7 +21,15 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm' -import { MODALITIES, resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts' +import { + CACHE_CONTROL_FORMATS, + CHAT_TEMPLATE_VARS, + MAX_TOKENS_FIELDS, + MODALITIES, + resolveRouteModels, + SUPPORTED_THINKING_FORMATS, + THINKING_LEVELS, +} from './catalog.ts' import type { PiAiCompatProfile, PiAiModality, @@ -217,7 +225,7 @@ const chatTemplateKwarg: z = z.union([ z.boolean(), z.const(null), z.object({ - $var: z.union(['thinking.enabled', 'thinking.effort'] as const).required(), + $var: z.union(CHAT_TEMPLATE_VARS).required(), omitWhenOff: z.boolean(), }), ]) @@ -227,7 +235,7 @@ const compatProfile: z = z.object({ supportsDeveloperRole: z.boolean(), supportsReasoningEffort: z.boolean(), supportsUsageInStreaming: z.boolean(), - maxTokensField: z.union(['max_completion_tokens', 'max_tokens'] as const), + maxTokensField: z.union(MAX_TOKENS_FIELDS), requiresToolResultName: z.boolean(), requiresAssistantAfterToolResult: z.boolean(), requiresThinkingAsText: z.boolean(), @@ -235,7 +243,7 @@ const compatProfile: z = z.object({ thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS), chatTemplateKwargs: z.dict(chatTemplateKwarg), supportsStrictMode: z.boolean(), - cacheControlFormat: z.union(['anthropic'] as const), + cacheControlFormat: z.union(CACHE_CONTROL_FORMATS), supportsLongCacheRetention: z.boolean(), supportsEagerToolInputStreaming: z.boolean(), supportsCacheControlOnTools: z.boolean(), diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 8997556871..3591972c42 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -929,6 +929,44 @@ describe('compat switches', () => { })).toThrow(/its api is "acme-chat", which does not take it.*"acme-chat" offers no configurable compat/s) }) + it('refuses a valueless compat key rather than writing null over the catalog', () => { + // schemastery passes a YAML bare key through as null. Carried forward it + // would replace the installed entry's value, and pi-ai's `??` would then + // reach for its baseURL detection — the "written but not applied" outcome. + expect(() => resolveProfiles({ + 'acme-gateway': { + api: 'openai-completions', + baseURL: 'https://acme.test', + compat: { supportsDeveloperRole: null } as never, + models: [{ id: 'acme-a' }], + }, + })).toThrow(/compat "supportsDeveloperRole" with no value/) + }) + + it('refuses a valueless compat key on a model entry too', () => { + expect(() => resolveProfiles({ + deepseek: { + modelOverrides: { 'deepseek-v4-flash': { compat: { requiresReasoningContentOnAssistantMessages: null } } as never }, + }, + })).toThrow(/model "deepseek-v4-flash" sets compat "requiresReasoningContentOnAssistantMessages" with no value/) + }) + + it('serves the Responses compat type on every protocol pi-ai gives it to', () => { + // pi-ai types azure-openai-responses and openai-codex-responses with the + // same OpenAIResponsesCompat, so a switch settable on one is settable on all. + for (const route of ['azure-openai-responses', 'openai-codex']) { + const models = modelsOf({ [route]: { compat: { supportsDeveloperRole: false } } }, route) + const [first] = [...models.values()] + expect((first?.compat as { supportsDeveloperRole?: boolean }).supportsDeveloperRole).toBe(false) + } + }) + + it('serves the Bedrock compat type on its own protocol', () => { + const models = modelsOf({ 'amazon-bedrock': { compat: { supportsStrictMode: false } } }, 'amazon-bedrock') + const [first] = [...models.values()] + expect((first?.compat as { supportsStrictMode?: boolean }).supportsStrictMode).toBe(false) + }) + it('refuses a compat key no wire protocol declares instead of dropping it', () => { // The silent drop is what let an unreadable switch look applied: schemastery // passes unknown keys through, and resolution used to read only two fields. From 4a02791c9a72e89c8c442a0a0972d06ddfc89361 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 19 Aug 2026 12:47:24 +0800 Subject: [PATCH 108/137] fix(llm): address multimodal review findings --- ...-19-direct-deepseek-vision-input.i18n.yaml | 4 +- ...2026-08-19-direct-deepseek-vision-input.md | 2 +- ...6-08-19-direct-deepseek-vision-input.zh.md | 2 +- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/llm/llm-deepseek/src/serialize.ts | 14 +- .../llm/llm-deepseek/tests/adapter.e2e.ts | 61 --------- .../llm/llm-deepseek/tests/adapter.spec.ts | 32 ++++- .../llm/llm-deepseek/tests/serialize.spec.ts | 129 ++++++++++++++++++ packages/llm/llm-pi-ai/src/context.ts | 16 ++- packages/llm/llm-pi-ai/tests/context.spec.ts | 64 ++++++++- packages/llm/llm/tests/content.spec.ts | 13 ++ 15 files changed, 268 insertions(+), 83 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml index a2caf36d87..d46a50b6d3 100644 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.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-19-direct-deepseek-vision-input.md -2026-08-19-direct-deepseek-vision-input.md: 60f55f10e108a29d465e388fa385b1955b7f2ce5 -2026-08-19-direct-deepseek-vision-input.zh.md: 7f41d89ffd13da1b1b170f114dfc481e66075ef2 +2026-08-19-direct-deepseek-vision-input.md: 5c2f2ea3ff87b58906ce76caf9f611e07f3e2d04 +2026-08-19-direct-deepseek-vision-input.zh.md: 3aea6b6a4f27f7116c1cb935b1719dfbcf7ed8fc diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md index 60f55f10e1..5c2f2ea3ff 100644 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.md @@ -27,7 +27,7 @@ Canonical messages continue to store only `ImageAttachmentRef`. Data URLs exist ## Verification -Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. A key-gated real-API e2e sends a deterministic image to the official vision model. +Package tests pin model discovery and fallback capabilities, configuration validation and live settings updates, user and tool-result wire messages, all admitted MIME types, cancellation, attachment failures, 413 classification, exact image-bound behavior, and pi-ai equivalence. A keyless assembled ACP request records the native adapter's tool-result data URL and oldest-image placeholder. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md index 7f41d89ffd..3aea6b6a4f 100644 --- a/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md +++ b/.agents/notes/implemented/feature/2026-08-19-direct-deepseek-vision-input.zh.md @@ -27,7 +27,7 @@ DeepSeek 视觉部署使用 chat-completions 图片协议,但直接 `deepseek- ## Verification -包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。受密钥控制的真实 API e2e 会向官方视觉模型发送一张确定性图片。 +包测试固定模型发现与回退能力、配置校验与存活 settings 更新、user 和工具结果协议消息、所有已准入 MIME 类型、取消、附件失败、413 分类、确切图片上限行为和 pi-ai 等价性。无需密钥的组装 ACP 请求会记录原生适配器的工具结果 data URL 与最旧图片占位文本。 ## Consequences diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7f1120a1df..6a9a3acf1f 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -3,4 +3,4 @@ # 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: 7d42923c1bc4c73636b52713694ff8f6eeb9146a -config-catalog.zh.md: b9327fa127ba8e341a15954685942f2d15d6b8d3 +config-catalog.zh.md: 327a4951c513e7cd2360488026e7578e95458f47 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b9327fa127..327a4951c5 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -897,7 +897,7 @@ export interface DeepSeekCatalogModel { 依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/llm/llm-deepseek/src/index.ts:72`](../packages/llm/llm-deepseek/src/index.ts) +来源:[`packages/llm/llm-deepseek/src/index.ts:66`](../packages/llm/llm-deepseek/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1b0e8fad9c..eea3a0a930 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: 0bd5f80534ba65e0d483bd04228cdac321e082bc -module-graph.zh.md: 6239520b7d819eb14c2b859afa44a0c987545200 +module-graph.md: 5398515912c98962152d2b121b1b02d7a06097b9 +module-graph.zh.md: 7a398374603d66f8a3661a1d927b6418a99235a6 diff --git a/docs/module-graph.md b/docs/module-graph.md index 0bd5f80534..5398515912 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -407,6 +407,7 @@ flowchart TD pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_attachment pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment @@ -1463,7 +1464,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6239520b7d..7a39837460 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -409,6 +409,7 @@ flowchart TD pkg_settings_file --> pkg_invariants pkg_settings_file --> pkg_settings pkg_llm_deepseek --> pkg_anonymous_user_id + pkg_llm_deepseek --> pkg_attachment pkg_llm_deepseek --> pkg_credentials pkg_llm_deepseek --> pkg_invariants pkg_llm_deepseek --> pkg_launch_environment @@ -1465,7 +1466,7 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index bdf9869544..da643aa599 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -148,9 +148,12 @@ async function contentParts( /** Keep text-only user messages on the compact string wire form. */ function userContent(parts: readonly WireUserContentPart[]): string | WireUserContentPart[] { - return parts.some(part => part.type === 'image_url') - ? [...parts] - : parts.map(part => part.type === 'text' ? part.text : '').join('') + const text: string[] = [] + for (const part of parts) { + if (part.type === 'image_url') return [...parts] + text.push(part.text) + } + return text.join('') } /** Serialize one assistant message (text + reasoning + tool calls). */ @@ -268,11 +271,12 @@ export async function serializeMessagesWithImages( const toolResults = message.content.filter((block): block is Extract => ( block.type === 'tool-result' )) - if (regular.length > 0 || toolResults.length === 0) { + const content = userContent(await contentParts(regular, attachments, signal)) + if (content.length > 0 || toolResults.length === 0) { flushToolImages() wire.push({ role: 'user', - content: userContent(await contentParts(regular, attachments, signal)), + content, }) } for (const result of toolResults) { diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 7845157fab..19ce411406 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,18 +1,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createHash } from 'node:crypto' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' -import AttachmentStore, { AttachmentId } from '@deepseek-ai/dsh-attachment' -import type { - ImageAttachmentLimits, - ImageAttachmentRef, - SaveImageAttachment, - StoredImageAttachment, -} from '@deepseek-ai/dsh-attachment' import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' @@ -26,41 +18,6 @@ import { assemble, type AssembledResult } from './assemble.ts' const FLASH = 'deepseek-v4-flash' const PRO = 'deepseek-v4-pro' -const VISION = 'deepseek-v4-flash-vision-exp' -const RED_IMAGE = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', - 'base64', -) -const RED_IMAGE_REF: ImageAttachmentRef = { - attachmentId: AttachmentId(`sha256:${createHash('sha256').update(RED_IMAGE).digest('hex')}`), - mediaType: 'image/png', - bytes: RED_IMAGE.byteLength, - width: 1, - height: 1, -} - -class E2eAttachmentStore extends AttachmentStore { - readonly imageLimits: ImageAttachmentLimits = { - maxImageBytes: 1024, - maxImagesPerMessage: 1, - maxMessageImageBytes: 1024, - maxImagePixels: 1, - maxImageDimension: 1, - mediaTypes: ['image/png'], - } - - validateImage(_input: SaveImageAttachment): Promise { - return Promise.resolve() - } - - saveImage(_input: SaveImageAttachment): Promise { - return Promise.resolve(RED_IMAGE_REF) - } - - readImage(_ref: ImageAttachmentRef, _signal?: AbortSignal): Promise { - return Promise.resolve({ ref: RED_IMAGE_REF, data: RED_IMAGE }) - } -} const contexts: Context[] = [] let identityHome: string @@ -73,7 +30,6 @@ async function harness(_model: string, config: Partial = {}) { const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmRuntime) - await ctx.plugin(E2eAttachmentStore) await ctx.plugin(LlmDeepSeek, config) return ctx } @@ -109,23 +65,6 @@ const weatherTool: ToolSchema = { } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => { - it('recognizes a deterministic image with the official vision model', async () => { - const ctx = await harness(VISION, { thinking: 'disabled' }) - const result = await assemble(ctx, { - model: VISION, - messages: [createUserMessage({ - content: [ - { type: 'text', text: 'This image is one solid color. Reply with only its English color name.' }, - { type: 'image', attachment: RED_IMAGE_REF }, - ], - source: { kind: 'plugin', plugin: 'test' }, - })], - maxTokens: 50, - }) - expect(result.finish.kind).toBe('stop') - expect(textOf(result).toLowerCase()).toContain('red') - }) - it('serves a real request with the key held only by a credentials-local document', async () => { const key = process.env.DEEPSEEK_API_KEY if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY') diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index cecb37bdff..6d86691e5c 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -178,7 +178,10 @@ describe('DeepSeekAdapter against a mock server', () => { const server = await mockServer([]) const resolveApiKey = vi.fn(() => Promise.resolve('k')) const adapter = new DeepSeekAdapter({ - options: () => resolveAdapterOptions({ baseURL: server.url }), + options: () => resolveAdapterOptions({ + baseURL: server.url, + models: [{ id: 'deepseek-v4-flash-vision-exp', inputModalities: ['text', 'image'] }], + }), resolveApiKey, resolveUserId: () => TEST_USER_ID, }) @@ -865,6 +868,21 @@ describe('plugin registration and config', () => { ]) }) + it('defaults an adapter-supplied catalog entry to text input', async () => { + const connection = resolveAdapterOptions({ models: [] }) + const adapter = new DeepSeekAdapter({ + options: () => ({ ...connection, models: [{ id: 'adapter-model' }] }), + resolveApiKey: () => Promise.resolve('k'), + resolveUserId: () => TEST_USER_ID, + }) + await expect(adapter.listModels('deepseek-official')).resolves.toEqual([{ + provider: 'deepseek-official', + id: 'adapter-model', + name: 'adapter-model', + inputModalities: ['text'], + }]) + }) + it('advertises configured models without restricting arbitrary request ids', async () => { const ctx = new Context() await ctx.plugin(LlmRuntime) @@ -954,6 +972,18 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + const invalidProgrammaticModalities: Array<[LlmDeepSeek.DeepSeekCatalogModel[], RegExp]> = [ + [[{ id: 'm', inputModalities: [] }], /inputModalities must not be empty/], + [[{ + id: 'm', + inputModalities: ['audio'] as unknown as NonNullable, + }], /inputModalities must contain only "text" and "image"/], + ] + + it.each(invalidProgrammaticModalities)('rejects programmatic modality config that bypasses the schema', (models, message) => { + expect(() => resolveAdapterOptions({ models: [...models] })).toThrow(message) + }) + it.each([0, 1.5])('rejects a per-model output cap of %s', (maxTokens) => { expect(() => resolveAdapterOptions({ models: [{ id: 'bad-cap', maxTokens }] })) .toThrow(/maxTokens must be a positive integer/) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 059d6c0a54..d503a8b5c7 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -393,6 +393,95 @@ describe('image serialization', () => { ]) }) + it('does not emit an empty user message for ignored content beside a tool result', async () => { + const messages = [createUserMessage({ + content: [ + { type: 'text', text: '' }, + { type: 'chart', data: 'ignored' } as unknown as ContentBlock, + { + type: 'tool-result', + toolCallId: CallId('result'), + content: [{ type: 'text', text: 'ok' }], + }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })] + + await expect(serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + )).resolves.toEqual([ + { role: 'tool', tool_call_id: 'result', content: 'ok' }, + ]) + }) + + it('recursively converts nested tool-result content and preserves the empty fallback', async () => { + const messages = [createUserMessage({ + content: [ + { + type: 'tool-result', + toolCallId: CallId('nested'), + content: [{ + type: 'tool-result', + toolCallId: CallId('inner'), + content: [{ type: 'text', text: 'inside' }], + }], + }, + { type: 'tool-result', toolCallId: CallId('empty'), content: [] }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })] + + await expect(serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + )).resolves.toEqual([ + { role: 'tool', tool_call_id: 'nested', content: 'inside' }, + { role: 'tool', tool_call_id: 'empty', content: '(no output)' }, + ]) + }) + + it('flushes tool-result images before system and assistant history', async () => { + const imageResult = (id: string) => createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId(id), + content: [{ type: 'image', attachment: imageRef() }], + }], + source: { kind: 'plugin' as const, plugin: 'test' }, + }) + const messages = [ + imageResult('before-system'), + createMessage({ + role: 'system', + content: [{ type: 'text', text: 'system history' }], + source: { kind: 'plugin', plugin: 'test' }, + }), + imageResult('before-assistant'), + createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'assistant history' }], + source: { kind: 'plugin', plugin: 'test' }, + }), + ] + + const wire = await serializeMessagesWithImages( + messages, + attachmentStore(), + new AbortController().signal, + ) + expect(wire).toEqual([ + { role: 'tool', tool_call_id: 'before-system', content: '(see attached image)' }, + expect.objectContaining({ role: 'user' }), + { role: 'system', content: 'system history' }, + { role: 'tool', tool_call_id: 'before-assistant', content: '(see attached image)' }, + expect.objectContaining({ role: 'user' }), + { role: 'assistant', content: 'assistant history' }, + ]) + }) + it('offloads oldest images before reads and keeps the newest image', async () => { const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, @@ -435,6 +524,37 @@ describe('image serialization', () => { expect(readImage).not.toHaveBeenCalled() }) + it('rejects unsupported image history before request offloading can replace it', async () => { + const readImage = vi.fn() + await expect(serializeRequestWithImages(request({ + messages: [createMessage({ + role: 'system', + content: [{ type: 'image', attachment: imageRef('image/png', 300) }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(readImage), + maxRequestImageBytes: 1, + signal: new AbortController().signal, + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(readImage).not.toHaveBeenCalled() + }) + + it('prepends the request system prompt on the image path', async () => { + const wire = await serializeRequestWithImages(request({ + system: 'system prompt', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }), { + attachments: attachmentStore(), + maxRequestImageBytes: 20 * 1024 * 1024, + signal: new AbortController().signal, + }) + expect(wire.messages[0]).toEqual({ role: 'system', content: 'system prompt' }) + }) + it('preserves stable attachment failure codes', async () => { const readImage = vi.fn(() => Promise.reject(new AttachmentError( 'Stored attachment bytes are corrupt.', @@ -446,6 +566,15 @@ describe('image serialization', () => { })], attachmentStore(readImage), new AbortController().signal)) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) }) + + it('preserves non-attachment resolver failures', async () => { + const failure = new Error('resolver failed') + const readImage = vi.fn(() => Promise.reject(failure)) + await expect(serializeMessagesWithImages([createUserMessage({ + content: [{ type: 'image', attachment: imageRef() }], + source: { kind: 'plugin', plugin: 'test' }, + })], attachmentStore(readImage), new AbortController().signal)).rejects.toBe(failure) + }) }) describe('review fixes: assistant content shapes', () => { diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index c0391b7bad..d66a48115d 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -26,6 +26,18 @@ function toolResultText(blocks: readonly ContentBlock[]): string { : block.type === 'tool-result' ? toolResultText(block.content) : '').join('') } +/** Reject image roles that pi-ai cannot replay before request-size offloading can replace them. */ +function assertSupportedImageRoles(messages: readonly Message[]): void { + for (const message of messages) { + if (message.role !== 'user' && contentHasImage(message.content)) { + throw new LlmError( + `pi-ai cannot represent an image in an in-history ${message.role} message`, + 'UNSUPPORTED_CONTENT', + ) + } + } +} + async function userContent( blocks: readonly ContentBlock[], attachments: AttachmentStore, @@ -169,15 +181,13 @@ async function toPiContextWithImages( onReplayDegrade?: (reason: string) => void, maxRequestImageBytes?: number, ): Promise { + assertSupportedImageRoles(options.messages) const requestMessages = offloadRequestImages(options.messages, maxRequestImageBytes) const toolNames = new Map() const messages: PiMessage[] = [] for (const message of requestMessages) { if (message.role === 'system') { - if (contentHasImage(message.content)) { - throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') - } // pi-ai has a single systemPrompt slot; in-history system messages are // folded into user messages to preserve order (rare in practice — the // harness sends the system prompt via options.system). diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 2ab90d0c21..b82c6b63f5 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -140,6 +140,59 @@ describe('pi-ai request context conversion', () => { ]) }) + it('recursively converts nested tool-result text and images', async () => { + const callId = CallId('nested-call') + const context = await toPiContext(request([user([{ + type: 'tool-result', + toolCallId: callId, + content: [ + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'nested text' }], + }, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'image', attachment: ref }], + }, + ], + }])]), attachments) + + expect(context.messages).toEqual([{ + role: 'toolResult', + toolCallId: 'nested-call', + toolName: 'unknown', + content: [ + { type: 'text', text: 'nested text' }, + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + ], + isError: false, + timestamp: 0, + }]) + }) + + it('flattens nested text-only tool results and ignores other block types without storage', () => { + const callId = CallId('nested-text') + expect(toPiContext(request([user([{ + type: 'tool-result', + toolCallId: callId, + content: [ + { type: 'chart', data: 'ignored' } as unknown as ContentBlock, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'nested' }], + }, + ], + }])]))).toMatchObject({ + messages: [{ + role: 'toolResult', + content: [{ type: 'text', text: 'nested' }], + }], + }) + }) + it('replaces the oldest images with placeholders once the request payload bound is exceeded', async () => { const readImage = vi.fn(() => Promise.resolve({ ref: { ...ref, bytes: 3 }, data: Uint8Array.of(1, 2, 3) })) const store = { readImage } as unknown as AttachmentStore @@ -249,9 +302,14 @@ describe('pi-ai request context conversion', () => { }) it('handles in-history system and assistant messages explicitly on the image path', async () => { - await expect(toPiContext(request([ - history('system', [{ type: 'image', attachment: ref }]), - ]), attachments)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + for (const role of ['system', 'assistant'] as const) { + const readImage = vi.fn() + const store = { readImage } as unknown as AttachmentStore + await expect(toPiContext(request([ + history(role, [{ type: 'image', attachment: ref }]), + ]), store, undefined, 1)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + expect(readImage).not.toHaveBeenCalled() + } await expect(toPiContext(request([ history('system', [{ type: 'text', text: 'history system' }]), diff --git a/packages/llm/llm/tests/content.spec.ts b/packages/llm/llm/tests/content.spec.ts index e61a585230..ffb5a586bf 100644 --- a/packages/llm/llm/tests/content.spec.ts +++ b/packages/llm/llm/tests/content.spec.ts @@ -73,4 +73,17 @@ describe('offloadRequestImages', () => { expect(offloadRequestImages(messages, 8)[0]?.content) .toEqual([{ type: 'text', text: OFFLOADED_IMAGE_TEXT }]) }) + + it('keeps unchanged nested content while replacing a later image', () => { + const nested = { + type: 'tool-result' as const, + toolCallId: CallId('text-only'), + content: [{ type: 'text' as const, text: 'kept' }], + } + const messages = [createUserMessage({ content: [nested, image(3)], source })] + expect(offloadRequestImages(messages, 1)[0]?.content).toEqual([ + nested, + { type: 'text', text: OFFLOADED_IMAGE_TEXT }, + ]) + }) }) From 1c77a78d6ac16a49f6821a60fa1ae285ae5e885d Mon Sep 17 00:00:00 2001 From: Magolor Date: Wed, 19 Aug 2026 11:21:29 +0800 Subject: [PATCH 109/137] perf(session): reuse immutable persistence seed --- .../session-persistence/src/coordinator.ts | 2 +- .../tests/persistence.spec.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index eb5f9714c4..5bb182b925 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -1170,7 +1170,7 @@ export class PersistenceCoordinator { this.live.set(session, restored) return restored } - const seed = session.events.map(e => structuredClone(e)) + const seed = session.events const live: LiveSessionState = { init: Promise.resolve(), writes: this.createWriteBehind(session, () => live.init), diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index 50ad798e04..1e66fec709 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -180,6 +180,7 @@ class ControlledBackend implements PersistenceBackend { readonly name = 'session-persistence-controlled' readonly store: MemoryStore = new Map() readonly lifecycle: string[] = [] + lastAppendedBatch: readonly SessionEvent[] | undefined appendAttempts = 0 loadAttempts = 0 repairAttempts = 0 @@ -212,6 +213,7 @@ class ControlledBackend implements PersistenceBackend { } async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise { + this.lastAppendedBatch = events const attempt = ++this.appendAttempts await this.beforeAppend?.(attempt) const entry = this.store.get(m.id) @@ -280,6 +282,26 @@ runCoordinatorContract('memory', async (): Promise => { }) describe('PersistenceCoordinator bounded writes', () => { + it('retains the immutable session seed without cloning it', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const session = ctx.sessions.create(SessionId('shared-seed'), { seed: oneTurnLog() }) + const seed = session.events + await ctx.sessions.flush(session) + + expect(backend.lastAppendedBatch).toBe(seed) + } finally { + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + it('cancels the batching deadline when live initialization rejects', async () => { vi.useFakeTimers() const ctx = new Context() From 53c4a4a1748b3c9e274581a1710bb3af7d6ac781 Mon Sep 17 00:00:00 2001 From: Magolor Date: Wed, 19 Aug 2026 13:30:53 +0800 Subject: [PATCH 110/137] docs(session): document persistence seed ownership --- .../2026-06-18-shared-persistence-write-coordinator.i18n.yaml | 4 ++-- .../2026-06-18-shared-persistence-write-coordinator.md | 4 ++++ .../2026-06-18-shared-persistence-write-coordinator.zh.md | 4 ++++ packages/session/session-persistence/src/coordinator.ts | 1 + .../session/session-persistence/tests/persistence.spec.ts | 4 +++- 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 38a665a0fd..85a8dd684b 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.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-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 93b6cd1bd058499e71948d3909de8e4c076b445e -2026-06-18-shared-persistence-write-coordinator.zh.md: 9e1bc736d4dba5e59b763710976425fa0ae76c30 +2026-06-18-shared-persistence-write-coordinator.md: 286bbb7d5cd3720109db0d0abc0bb72ddbfcbdcd +2026-06-18-shared-persistence-write-coordinator.zh.md: d24398c4bc9445739b4cdeb9f2ed176577060932 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 93b6cd1bd0..286bbb7d5c 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -16,6 +16,10 @@ Composition, not inheritance. The coordinator is a concrete class the backend ho The coordinator holds one lifecycle entry for each exact live `Session`: initialization plus a package-private write controller that owns pending events, a fixed batching deadline, the active write, failure retention, and the shared flush barrier. Each `session/event` enters that bounded write path, and `session/flush` bypasses the wait to observe quiescence. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns controller consolidation; the [bounded batching decision](2026-08-08-bounded-session-persistence-write-batching.md) owns scheduling cadence. +Creation borrows the exact `Session.events` snapshot as its persistence seed. `Session` has already detached, validated, and deeply frozen every event, and the snapshot array remains stable when later appends replace the cached view. The coordinator and its backend hooks only read this typed in-process value, so cloning the complete log again would duplicate the ownership work described by the [agent-scope runtime decision](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify). Public persistence `append()` still snapshots caller-owned input at its API boundary. + +Prepared-session suffixes and events admitted to the write-behind queue retain their existing copies. Those paths establish asynchronous queue ownership one suffix or event at a time and have no measured whole-log clone cost; removing their copies remains a separate ownership audit rather than part of creation-seed borrowing. + The coordinator retires a session from `session/disposed`: it waits for the controller's initialization and current flush, serializes a final drain, and removes the controller and owned per-id state only after success. A failure leaves the controller discoverable for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still current, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters write-path listeners, flushes every remaining controller, awaits per-id operations, and then closes the backend. ### The hook interface (`PersistenceBackend`) diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 9e1bc736d4..d24398c4bc 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -16,6 +16,10 @@ Status: implemented 协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。 +创建流程将 `Session.events` 的原始快照借作持久化种子。`Session` 已经分离、验证并深度冻结每个事件,后续追加会替换缓存视图,因此该快照数组保持稳定。协调器及其后端钩子只读取这个有类型的进程内值;再次克隆完整日志会重复 [agent scope 运行时决策](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify)规定的所有权工作。持久化服务的公开 `append()` 仍在 API 边界为调用方拥有的输入创建快照。 + +已准备 Session 的后缀,以及进入 write-behind 队列的事件,仍保留现有复制。这些路径会逐个后缀或事件建立异步队列所有权,且没有已测得的完整日志克隆成本;移除这些复制属于单独的所有权审计,不属于创建种子的借用决策。 + 协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。 ### 钩子接口(`PersistenceBackend`) diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 5bb182b925..63def62528 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -1170,6 +1170,7 @@ export class PersistenceCoordinator { this.live.set(session, restored) return restored } + // Session owns this stable deep-frozen snapshot; backends only serialize it. const seed = session.events const live: LiveSessionState = { init: Promise.resolve(), diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index 1e66fec709..6c63d72b9f 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -281,7 +281,7 @@ runCoordinatorContract('memory', async (): Promise => { } }) -describe('PersistenceCoordinator bounded writes', () => { +describe('PersistenceCoordinator seed ownership', () => { it('retains the immutable session seed without cloning it', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -301,7 +301,9 @@ describe('PersistenceCoordinator bounded writes', () => { await ctx.fiber.dispose() } }) +}) +describe('PersistenceCoordinator bounded writes', () => { it('cancels the batching deadline when live initialization rejects', async () => { vi.useFakeTimers() const ctx = new Context() From 30a838cda3fb2abeee90435b02c9b8e324dfd602 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 19 Aug 2026 13:33:08 +0800 Subject: [PATCH 111/137] docs(user): guide gateway request-compatibility switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Models page has no field for `compat`, and the symptom it addresses — a gateway holding a working key at a reachable address while refusing every request — reads as a credential or connectivity problem. Give it the same treatment `input` already has: name the symptom, show the two switches that account for most of it, and state the resolution order. Refs #2646 --- docs/user/guide/providers.i18n.yaml | 4 ++-- docs/user/guide/providers.md | 37 +++++++++++++++++++++++++++++ docs/user/guide/providers.zh.md | 37 +++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index faff9bb2ee..3a81ba6a33 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: 099f434ec4602aa402239e83c708d81fcadd7732 -providers.zh.md: 367c90b525ad628b3cd86b2d22045c25064e88a1 +providers.md: 969ca13c0b288c8c0c313318a7a3162d7c4b361e +providers.zh.md: 51079896ca6f38e30a65d7e73170a4a7e7b15569 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 099f434ec4..969ca13c0b 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -79,6 +79,40 @@ Every list must name at least one modality except a model's own, where an empty Both fields state a claim about your endpoint rather than checking it. A model that declares images its endpoint does not serve is not caught here; the provider rejects the request instead. +### Request compatibility + +A gateway can hold a working key at a reachable address and still refuse every request. pi-ai decides the shape of a request — which role carries the system prompt, which field caps the output, how a thinking level travels — from the endpoint's URL, and an address it does not recognize is addressed as though it were OpenAI itself. Most OpenAI-compatible gateways refuse at least one thing OpenAI accepts. + +Two account for most of it. A model that declares reasoning has its system prompt sent as `role: "developer"`, which many gateways reject outright, and the output cap is sent as `max_completion_tokens`, which a server that only knows `max_tokens` refuses. The form has no field for either; correct them on the route in `$DSH_HOME/settings.yaml`: + +```yaml +llm-pi-ai: + providers: + my-gateway: + apiKeyEnv: GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.example/v1 + compat: + supportsDeveloperRole: false + maxTokensField: max_tokens + models: + - id: my-model +``` + +A route's `compat` is the default for its models, and a model's own wins field by field, so one model can be corrected without restating the route: + +```yaml + models: + - id: my-model + - id: my-reasoner + compat: + thinkingFormat: deepseek +``` + +What neither sets keeps the installed catalog's value for that model, and what the catalog does not describe falls to pi-ai's detection. Give every switch you name a value: a key left empty (`supportsDeveloperRole:`) is refused rather than ignored, because an empty value would erase what the catalog knows while saying nothing in its place. A name no protocol accepts is refused too, and the message lists the ones that are available. + +Each switch belongs to the protocols that declare it, so a switch valid on one `api` may be refused on another — the message names what that protocol does offer. Like `input` above, a switch states a claim about your endpoint rather than checking it: setting one your gateway does not actually need simply sends a different request. + ## Select a model Configured providers appear in the model picker. Selecting a model also makes it the default for new sessions. A session that has already sent a request retains the model recorded in its own log. @@ -90,6 +124,9 @@ If a saved default names a provider that was deleted, the composer displays **Se - **`MISSING_CREDENTIAL`** — Store the provider key through the Models page or supply the referenced environment variable. - **`UNKNOWN_MODEL`** — Select a configured model or add the missing model to the custom provider. - **Fetching available models returns 401** — Check the key. Model discovery calls the OpenAI-compatible `GET /models` endpoint; enter models manually for endpoints that do not provide it. +- **The gateway refuses every request although the key and URL are right** — Its request shape differs from OpenAI's. Start with `compat.supportsDeveloperRole: false` and `compat.maxTokensField: max_tokens` on the route. +- **Only reasoning models fail** — pi-ai sends their system prompt as the `developer` role, which the gateway rejects. Set `compat.supportsDeveloperRole: false`. +- **A compat switch is refused as having no value** — A key written with nothing after the colon. Give it a value, or remove the key to keep the installed catalog's. - **An image is refused before sending** — The model declares no image modality. Give a custom provider's model `input: [text, image]`; DeepSeek's own chat-completions route is text-only and cannot be configured otherwise. - **The provider rejects a request carrying an image** — The model declares images its endpoint does not actually serve. Remove `image` from whichever list granted it — the model's `input`, or the route's `defaultInput` — then start a new session: the attached image stays in the session log, so the same request repeats until the session moves off it. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 367c90b525..51079896ca 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -79,6 +79,40 @@ llm-pi-ai: 这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。 +### 请求兼容性 + +网关可能持有可用的密钥、地址也通得到,却仍然拒绝每一个请求。pi-ai 依据端点的 URL 决定请求的形状——系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输——而对于它无法识别的地址,会当作 OpenAI 本身来对待。多数 OpenAI 兼容网关至少会拒绝 OpenAI 所接受的某一样东西。 + +其中两样占了绝大多数。声明了推理能力的模型,其系统提示词会以 `role: "developer"` 发出,很多网关直接拒绝;输出上限则写作 `max_completion_tokens`,只认 `max_tokens` 的服务端会拒绝。表单里没有这两个字段;请在 `$DSH_HOME/settings.yaml` 的路由上更正: + +```yaml +llm-pi-ai: + providers: + my-gateway: + apiKeyEnv: GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.example/v1 + compat: + supportsDeveloperRole: false + maxTokensField: max_tokens + models: + - id: my-model +``` + +路由的 `compat` 是其模型的默认值,模型自身的则逐字段胜出,因此更正某一个模型无需重述整条路由: + +```yaml + models: + - id: my-model + - id: my-reasoner + compat: + thinkingFormat: deepseek +``` + +两者都未设置的字段,沿用已安装 catalog 为该模型记录的值;catalog 也未描述的,落到 pi-ai 的检测。凡是写下的开关都要给值:冒号后留空的键(`supportsDeveloperRole:`)会被拒绝而不是被忽略,因为空值会抹掉 catalog 已知的信息,却又没有给出任何替代。任何协议都不接受的名字同样会被拒绝,报错会列出可用的那些。 + +每个开关归属于声明了它的那些协议,因此在某个 `api` 上合法的开关,在另一个上可能被拒绝——报错会点名该协议实际提供哪些。与上面的 `input` 一样,开关陈述的是关于你的端点的一个断言,而不是对它的检查:设置一个网关其实并不需要的开关,只是发出一个不同的请求而已。 + ## 选择模型 已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 @@ -90,6 +124,9 @@ llm-pi-ai: - **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。 - **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型。 - **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型。 +- **密钥与地址都正确,网关却拒绝每一个请求**:它的请求形状与 OpenAI 不同。先在路由上设 `compat.supportsDeveloperRole: false` 与 `compat.maxTokensField: max_tokens`。 +- **只有推理模型失败**:pi-ai 把它们的系统提示词以 `developer` 角色发出,而网关拒绝该角色。设 `compat.supportsDeveloperRole: false`。 +- **某个 compat 开关因没有值而被拒绝**:冒号后什么都没写。给它一个值,或删掉该键以沿用已安装 catalog 的值。 - **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;DeepSeek 自身的 chat-completions 路由是纯文本的,且无法通过配置改变。 - **提供方拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。 From c4037732ae0c729425ce3323665a3dc2ffd457f3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 19 Aug 2026 13:46:39 +0800 Subject: [PATCH 112/137] docs(user): point the provider guide at the adapter's catalog section The full switch list already exists and is generated from source, so the guide needs a way in rather than a copy: `config-catalog.md` carries 107 plugin sections, and linking the whole file leaves a reader to find the one that configures the page they are on. Both mentions now deep-link the `dsh-llm-pi-ai` anchor. Refs #2646 --- docs/user/guide/providers.i18n.yaml | 4 ++-- docs/user/guide/providers.md | 4 +++- docs/user/guide/providers.zh.md | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 3a81ba6a33..1942599b5d 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: 969ca13c0b288c8c0c313318a7a3162d7c4b361e -providers.zh.md: 51079896ca6f38e30a65d7e73170a4a7e7b15569 +providers.md: 59682d38c71893c16118639cb2b24f63497a3c13 +providers.zh.md: a9deac5c8ba46950af75b02e631aa83c56a4b72f diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 969ca13c0b..59682d38c7 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -113,6 +113,8 @@ What neither sets keeps the installed catalog's value for that model, and what t Each switch belongs to the protocols that declare it, so a switch valid on one `api` may be refused on another — the message names what that protocol does offer. Like `input` above, a switch states a claim about your endpoint rather than checking it: setting one your gateway does not actually need simply sends a different request. +Every switch, its accepted values, and the protocols that take it are listed under `PiAiCompatProfile` in the [generated `dsh-llm-pi-ai` configuration reference](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) — which is derived from the source, so it cannot fall behind what the adapter accepts. + ## Select a model Configured providers appear in the model picker. Selecting a model also makes it the default for new sessions. A session that has already sent a request retains the model recorded in its own log. @@ -132,4 +134,4 @@ If a saved default names a provider that was deleted, the composer displays **Se ## Advanced configuration -The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors. +The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default for every plugin; [`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) is the provider section this page configures. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 51079896ca..a9deac5c8b 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -113,6 +113,8 @@ llm-pi-ai: 每个开关归属于声明了它的那些协议,因此在某个 `api` 上合法的开关,在另一个上可能被拒绝——报错会点名该协议实际提供哪些。与上面的 `input` 一样,开关陈述的是关于你的端点的一个断言,而不是对它的检查:设置一个网关其实并不需要的开关,只是发出一个不同的请求而已。 +全部开关、各自接受的取值,以及接受它们的协议,都列在[生成的 `dsh-llm-pi-ai` 配置参考](../../config-catalog.md#deepseek-aidsh-llm-pi-ai)的 `PiAiCompatProfile` 之下——该参考派生自源码,因此不会落后于适配器实际接受的内容。 + ## 选择模型 已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 @@ -132,4 +134,4 @@ llm-pi-ai: ## 进阶配置 -自动生成的[插件配置目录](../../config-catalog.md)列出所有受支持的字段与默认值。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 +自动生成的[插件配置目录](../../config-catalog.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供方段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 From e2ee5c0f5dc914b34a3e870e2c90c3f4505278d3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 13:22:38 +0800 Subject: [PATCH 113/137] feat(feedback): float the note editor in a popover, not inline in the actions row The message-feedback note editor previously expanded inline inside the assistant message's shared IconActions row, overflowing the row at every viewport and pushing the branch action and the clock out of the column. Rework it into a portable popover: the note editor is a fixed-position panel portaled to document.body and anchored to the note trigger, so the row keeps its single 28px line whether the editor is open or not and the panel escapes the column's overflow clip. The like/dislike buttons and the note trigger stay in the row unchanged; the trigger toggles the popover (textarea + Save/Cancel + note-save failure) which auto-focuses the input and closes on Escape or an outside click, returning focus to the trigger. Rating/list-load failures surface in the row, note-save failures inside the popover. Reuse the Menu portal surface tokens and add @types/react-dom so the createPortal usage typechecks. Layout e2e sweeps six viewports and pins that opening the editor leaves the row's overflow, line count, and out-of-column items unchanged, and that the panel is outside the column, within the viewport, and anchored to its trigger. Unit tests cover the popover's portal-to-body, toggle, Escape/outside-click dismissal, and unmount early-returns. --- ...-13-feedback-note-editor-popover.i18n.yaml | 6 + ...2026-08-13-feedback-note-editor-popover.md | 43 ++ ...6-08-13-feedback-note-editor-popover.zh.md | 43 ++ apps/web/tests/message-feedback-layout.e2e.ts | 336 ++++++++++++ .../geometry.expected.md | 10 + apps/web/tsconfig.json | 1 + .../ui-message-feedback/README.i18n.yaml | 4 +- packages/client/ui-message-feedback/README.md | 2 +- .../client/ui-message-feedback/README.zh.md | 2 +- .../client/ui-message-feedback/package.json | 1 + .../client/MessageFeedbackActions.module.css | 62 ++- .../src/client/MessageFeedbackActions.tsx | 259 +++++++-- .../ui-message-feedback/src/client/locales.ts | 2 + .../message-feedback-actions.client.spec.tsx | 492 +++++++++++++++++- .../tests/styles.client.spec.ts | 93 ++++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/index.ts | 2 + .../ui-primitives/src/useAnchoredPosition.ts | 81 +++ .../use-anchored-position.client.spec.tsx | 111 ++++ pnpm-lock.yaml | 3 + tsconfig.host.json | 1 + 23 files changed, 1496 insertions(+), 66 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md create mode 100644 apps/web/tests/message-feedback-layout.e2e.ts create mode 100644 apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md create mode 100644 packages/client/ui-message-feedback/tests/styles.client.spec.ts create mode 100644 packages/client/ui-primitives/src/useAnchoredPosition.ts create mode 100644 packages/client/ui-primitives/tests/use-anchored-position.client.spec.tsx diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.i18n.yaml new file mode 100644 index 0000000000..d84f437915 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.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-13-feedback-note-editor-popover.md +2026-08-13-feedback-note-editor-popover.md: 33b7cd84b97ceac7fef1d96f1dee279fbb215800 +2026-08-13-feedback-note-editor-popover.zh.md: b130ab1e9c66372cf2a52bc5e12f65027c64cf51 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md new file mode 100644 index 0000000000..33b7cd84b9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.md @@ -0,0 +1,43 @@ +# Agent Note: The feedback note editor floats above the transcript in a popover + +Status: implemented + +English | [中文](2026-08-13-feedback-note-editor-popover.zh.md) + +## Problem + +The Web surface for message feedback ([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262)) contributes its controls to `conversation.chat.assistant-actions`, which renders inside the finalized assistant message's shared IconActions row. That row was one fixed-height `flex` line with `flex-wrap` at its initial `nowrap` and `height: 28px`, sized for 28px icons and a clock. The note editor mounted into it as an inline group holding a `width: 260px` textarea plus Save and Cancel. + +A 260px input and two buttons do not fit that line at any window size. Measured against the shipped bundle, the row's scrollable overflow with the editor open was 168px at a 1680px viewport and 444px at 600px — the defect was never a narrow-window edge case, it was present at full-screen desktop. Flex overflow spills past the end of the line, so the items after the editor in flex order were the ones pushed out of the conversation column: the branch action left the column at 600px, and the clock and its run/TTFT/throughput readings left it at 900px. Those controls stay hit-testable while invisible, so no behavioral assertion noticed; the shipped e2e covered rate, note, reload, and retract, and the 24 UI snapshots are width-independent DOM. + +The same stylesheet also named four `--dsw-alias-*` tokens that the theme does not define: `border-secondary`, `bg-primary`, `interactive-bg-primary`, and `label-inverse`. An undefined custom property makes its whole declaration invalid at computed-value time, so the textarea shipped with no border and no surface, and Save with neither fill nor a readable label — the editor read as loose text floating in the transcript rather than as an input. + +## Decision + +The note editor does not enter the row's flex layout at all. It is a popover: a fixed-position panel, portaled to `document.body`, whose coordinates come from the note trigger's rect. The row keeps its single line of icons and the note trigger, so nothing has to shrink, wrap, or reflow around the editor, and no `order` or wrapping is needed anywhere. Portaling out of the conversation column also escapes its `overflow` clip, so the panel cannot be cropped at the scroll edge and it moves with the message it annotates when the transcript scrolls. This reuses the same portal mechanism `ui-primitives/Menu` uses for anchored menus (`ui-subagent`'s catalog popover is built on it): the panel is `position: fixed`, placed from the anchor rect on open, clamped inside the viewport, and re-placed on scroll (capture phase) and resize. That anchoring is shared rather than copied: `ui-primitives/useAnchoredPosition` owns measure-offset-clamp-and-track, and the duplication gate is what forced the extraction — an inline copy of the clamp and its listener pair reported a 10-line clone against `Menu`. `Menu` keeps its own effect because its placement also resolves `side`/`align` variants and an optional caller-supplied anchor rect, which this surface does not need; the hook covers the plain below-the-anchor case both would otherwise spell out. + +**The action strip.** The like/dislike buttons and the note trigger stay in the row, unchanged. The trigger is a plain button (`aria-haspopup="dialog"`, `aria-expanded` while open) that shows "Add a note" before a note exists and the note text afterward. + +**The popover.** While open, the panel contains the textarea plus Save and Cancel, and any note-save failure, as `role="dialog"` with a title distinct from the textarea's own label so both are addressable by name. It opens beneath the trigger (4px gap), clamps to 12px from the viewport edges, auto-focuses the textarea, and closes on Escape or an outside pointer-down. Closing returns focus to the trigger only when the panel was really open, never on the initial mount (a freshly rendered rated message must not pull focus into its action row). A rating action during an open editor closes the panel. The four undefined tokens are replaced with the ones the theme actually defines, matching the primitives' precedent: `border-l2` and `bg-layer-1` for the input, `button-primary-fill` with `label-primary-foreground` plus a `button-primary-hover` state for Save; the panel surface reuses the Menu card recipe (`--dsw-specific-menu`, `--dsw-shadow-lv3`, inverted hairline `--dsw-alias-border-inverted`, `border-radius: 12px`). + +**Failure surfaces split by where the human is looking.** A rating or list-load failure shows beside the buttons in the row, legible whether or not the popover is open. A note-save failure shows inside the popover, next to Save/Cancel, and the panel stays open so the draft survives to be corrected. + +## Alternatives considered + +**Inline expansion on the row, the editor claiming its own line via a full-width flex basis with the row allowed to wrap** — the approach first shipped on this branch and rejected here. It fixes the geometry (the row reports zero overflow from 1680px down to 600px) but at a visible cost: the branch action and the end clock wrap below the editor while it is open, the row occupies three lines, and the interaction competes for the same horizontal strip the row already fills. That cost is what [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) reported from real use — the row reads as misaligned once the editor expands — and it asked for the popover the chat surface already uses. A popover removes the editor from the row entirely, so the strip and the keyboard tab order are untouched whether the editor is open or not. + +**An absolutely-positioned popover not portaled out of the column** — rejected: the conversation column is an `overflow-y: auto` scroller, so a panel laid out inside it is clipped at the scroll edge and does not track the message as the column scrolls. Portaling to `document.body` with fixed placement from the trigger rect is what makes the floating panel viable, exactly as `Menu`'s portal mode and the subagent catalog popover already do. + +**A new `belowActions` seam on `MessageIconActions`, rendering the editor as a sibling under the row** — rejected: the slot contract documents `assistant-actions` as rendering *inside* the message's IconActions row, and one entry cannot supply two render sites without widening the host contract for a presentation detail that a portaled popover already expresses without touching the host. + +## Consequences + +With the editor open the actions row stays a single 28px line with zero overflow and nothing outside the column at every viewport from 1680px down to 600px — because the editor is not in the row to begin with. The panel floats above the transcript inside the viewport and stays anchored to its trigger, escaping the column's overflow clip. The editor is legible as an input in both themes. + +`apps/web/tests/message-feedback-layout.e2e.ts` sweeps six viewports with the editor open and pins, per stop, that the row reports one line and zero overflow, that the panel is outside the conversation column (proof it escapes the clip), that it lies within the viewport (proof the clamp holds), and that it sits by its trigger. A committed golden records the relations; reverting to inline (or dropping the portal) fails the geometry assertions. `packages/client/ui-message-feedback/tests/styles.client.spec.ts` checks the tokens against the theme's committed source, that the panel is `position: fixed`, and that it carries no flex sizing (so it cannot rejoin the row), plus the brace balance, following the `ui-settings-models` styles-spec precedent. The unit spec covers rate, note, reload, retract, plus the popover's portal-to-body, Escape/outside-click dismissal, and keep-open-on-inside-click. + +The `ui-message-feedback` package adds `@types/react-dom` so the `createPortal` usage typechecks, mirroring `ui-primitives`. + +Known limitations are accepted rather than fixed here. A rating click while the panel is open closes it, and the close path returns focus to the note trigger rather than leaving it on the rating button the human just pressed; the same happens when an outside click lands on another focusable control, which the browser focuses before the close returns focus to the trigger. A pointer user does not notice either; a keyboard user feels the focus move. The clamp assumes the panel fits: a panel taller than the viewport makes the upper bound `innerHeight - height - margin` smaller than `margin`, so `top` goes negative and the panel's head is cut off rather than its foot. The panel's three-row textarea carries `resize: vertical`, so a human can drag past that size; `.notePanel` therefore bounds its height at `calc(100vh - 24px)` and scrolls its own content, the counterpart of the existing `max-width` and the same 12px margin the clamp uses. If the rating disappears while the editor is open, the panel unmounts on the `rating !== undefined` guard but `noteOpen` stays true, so the document-level Escape and pointer-down listeners remain attached; should the item reappear through a later resync, the panel returns with the previous draft and without refocusing the textarea. The window is one click or Escape wide, and the save failure it could hide already falls back to the row, so it is left as it is. A failure that lands after the panel was closed and reopened is not written into the new session's panel: its draft was reseeded from the stored note, so an old attempt's error would mislabel it, and the uncommitted content is already gone — the failure is dropped rather than shown. And while the placement replays on scroll, window resize, and the panel's own size changes, jsdom has no layout, so the real geometry is proven by the browser scenario while the unit spec covers the wiring through a `ResizeObserver` stub. + +A residual narrow-viewport clock overflow remains below 520px from the clock string alone, unrelated to the feedback surface. The repo has no gate for undefined design tokens, and a scan during this work found more in `ui-agent-preset`, `ui-conversation`, `ui-jobs`, `ui-settings-plugins`, and `ui-tool`; they are untouched here and want their own change. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md new file mode 100644 index 0000000000..b130ab1e9c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-feedback-note-editor-popover.zh.md @@ -0,0 +1,43 @@ +# Agent Note:反馈备注编辑器以浮层悬浮在对话记录上方 + +Status: implemented + +[English](2026-08-13-feedback-note-editor-popover.md) | 中文 + +## Problem + +消息反馈的 Web 界面([#2262](https://github.com/deepseek-harness/deepseek-harness/pull/2262))把控件贡献给 `conversation.chat.assistant-actions`,该槽位渲染在已定稿助手消息共享的 IconActions 行内。那一行是单条固定高度的 `flex` 线,`flex-wrap` 保持初始值 `nowrap` 且 `height: 28px`,按 28px 图标加一个时钟来定尺寸。备注编辑器作为一个内联组挂进去,内含 `width: 260px` 的 textarea 加 Save 与 Cancel。 + +一个 260px 输入框加两个按钮在任何窗口尺寸下都装不进那条线。对着已构建产物实测,编辑器打开时该行的可滚动溢出在 1680px 视口下是 168px,在 600px 下是 444px——这个缺陷从来不是窄窗口的边缘情况,在全屏桌面下就已存在。flex 溢出会溢出到线的末端之外,因此按 flex 顺序排在编辑器之后的项被挤出会话列:branch 操作在 600px 时离开列,时钟及其运行时长/TTFT/吞吐读数在 900px 时离开列。这些控件在不可见的同时仍可命中测试,所以没有任何行为断言发现它;已交付的 e2e 覆盖评分、备注、reload 与撤回,而 24 个 UI 快照是与宽度无关的 DOM。 + +同一张样式表还引用了四个主题并未定义的 `--dsw-alias-*` token:`border-secondary`、`bg-primary`、`interactive-bg-primary` 与 `label-inverse`。未定义的自定义属性会让其所在的整条声明在 computed-value 阶段失效,因此 textarea 交付时既无边框也无底色,Save 既无填充也无可读标签——编辑器读起来像是浮在对话记录里的散落文本,而不是一个输入框。 + +## Decision + +备注编辑器完全不进入行的 flex 布局。它是一个浮层:一张固定定位的面板,portal 到 `document.body`,其坐标来自备注触发按钮的矩形。行保持其单行图标与备注触发按钮,因此没有任何东西需要围绕编辑器收缩、换行或回流,任何地方都不需要 `order` 或换行。portal 出会话列也逃出了列的 `overflow` 裁剪,因此面板不会被滚动边缘裁掉,并且当对话记录滚动时会随它所批注的消息一起移动。这里复用 `ui-primitives/Menu` 为锚定菜单所用的同一套 portal 机制(`ui-subagent` 的 catalog popover 就构建在它之上):面板 `position: fixed`,打开时从 anchor rect 定位,钳制在视口内,并在滚动(捕获阶段)与缩放时重新定位。这套锚定逻辑是共享而非复制的:`ui-primitives/useAnchoredPosition` 持有「测量—偏移—钳制—跟随」这一件事,而促成这次抽取的正是重复代码门禁——内联的钳制与那对监听器被报为与 `Menu` 的 10 行克隆。`Menu` 保留自己的 effect,因为它的定位还要解析 `side`/`align` 变体与可选的调用方 anchor rect,而本界面不需要这些;该 hook 覆盖的是两边本来都要各写一遍的「锚点正下方」这一简单情形。 + +**操作条。** 点赞/点踩按钮与备注触发按钮保持原样留在行内。触发按钮是普通 `button`(`aria-haspopup="dialog"`,打开时 `aria-expanded`),在没有备注时显示「补充说明」,已有备注时显示备注文本。 + +**浮层。** 打开时,面板内含 textarea、Save 与 Cancel,以及任何备注保存失败提示,作为 `role="dialog"`,其标题与 textarea 自身的标签不同,以便两者都能按名称寻址。它在触发按钮下方打开(4px 间距),钳制到距视口边缘 12px,自动聚焦 textarea,并在 Escape 或外部 pointer-down 时关闭。关闭时仅当面板确实曾经打开才把焦点还给触发按钮,绝不会在初始挂载时(新渲染出的一条已评分消息不得把焦点拉进其操作条)。编辑器打开时进行评分操作会关闭面板。四个未定义 token 换成主题确实定义的那些,与 primitives 的既有做法一致:输入框用 `border-l2` 与 `bg-layer-1`,Save 用 `button-primary-fill` 配 `label-primary-foreground` 并加 `button-primary-hover` 状态;面板表面复用 Menu 卡片的配方(`--dsw-specific-menu`、`--dsw-shadow-lv3`、反色发丝线 `--dsw-alias-border-inverted`、`border-radius: 12px`)。 + +**失败提示按人的视线所落之处拆分。** 评分或列表加载失败显示在按钮旁的图标行里,无论浮层是否打开都清晰可读。备注保存失败显示在浮层内、Save/Cancel 旁,且面板保持打开,以便草稿留存待修正。 + +## Alternatives considered + +**行内展开:编辑器通过整行 flex basis 独占一行,并让行允许换行** — 这是本分支最初交付、在此否决的做法。它修好了几何(行在 1680px 到 600px 报告零溢出),但有可见代价:branch 与末尾时钟在编辑器打开时换行到编辑器下方,行占三行,交互与行本就占满的横向条带争空间。这一代价正是 [#2561](https://github.com/deepseek-harness/deepseek-harness/issues/2561) 在真实使用中反馈的问题——编辑器展开后这一行读起来是错位的——并提出改用 chat 界面已有的弹窗。浮层把编辑器完全移出行,因此无论编辑器是否打开,操作条与键盘 Tab 顺序都不受影响。 + +**不 portal 出列的绝对定位浮层** — 否决:会话列是 `overflow-y: auto` 的滚动容器,因此在列内布局的面板会被滚动边缘裁掉,且不随列滚动而跟住消息。portal 到 `document.body` 并从触发按钮矩形做固定定位,才让浮动面板可行,正如 `Menu` 的 portal 模式与 subagent catalog popover 已然做到的那样。 + +**在 `MessageIconActions` 上新增 `belowActions` 接缝,把编辑器作为该行的兄弟节点渲染在下方** — 否决:slot 契约明确记载 `assistant-actions` 渲染在消息 IconActions 行**内部**,且单个条目无法在不为一个展示细节拓宽 Host 契约的前提下提供两个渲染点,而 portal 出的浮层无需触碰 Host 就表达了该细节。 + +## Consequences + +编辑器打开时,操作行保持单条 28px 线,在 1680px 到 600px 的每一档视口都零溢出、零项落在列外——因为编辑器本就不在行里。面板悬浮于对话记录之上、位于视口内,并保持锚定其触发按钮,逃出列溢出裁剪。编辑器在两种主题下都能被辨认为输入框。 + +`apps/web/tests/message-feedback-layout.e2e.ts` 在编辑器打开时扫描六个视口,并在每一档钉住:行报告单行零溢出、面板位于会话列之外(证明它逃出裁剪)、面板落在视口内(证明钳制有效)、面板紧贴其触发按钮。已提交的 golden 记录这些关系;回退到行内(或去掉 portal)会让几何断言失败。`packages/client/ui-message-feedback/tests/styles.client.spec.ts` 校验 token 与主题已提交的源一致、面板为 `position: fixed`、且不带任何 flex sizing(因此不会重新加入行),并校验大括号平衡,沿用 `ui-settings-models` styles spec 的先例。单元 spec 覆盖评分、备注、reload、撤回,外加浮层的 portal 到 body、Escape/外部点击关闭、以及浮层内部点击保持打开。 + +`ui-message-feedback` 包新增 `@types/react-dom`,使 `createPortal` 用法能通过类型检查,与 `ui-primitives` 一致。 + +有若干已知限制在此接受而非修复。面板打开时点击评分会关闭它,而关闭路径把焦点归还给备注触发按钮,而不是留在用户刚按下的评分按钮上;外部点击落在另一个可聚焦控件上时同理——浏览器先把焦点给该控件,随后关闭路径又把它拉回触发按钮。指针用户对两者都无感,键盘用户会察觉焦点移动。钳制假定面板放得下:面板高于视口时,上界 `innerHeight - height - margin` 会小于 `margin`,于是 `top` 变为负值、被裁掉的是面板顶部而非底部。面板里的三行 textarea 带 `resize: vertical`,用户可以拖过这个尺寸,因此 `.notePanel` 把自身高度限制在 `calc(100vh - 24px)` 并自行滚动内容——这是既有 `max-width` 的对应项,用的是与钳制相同的 12px 边距。编辑器打开时若评分消失,面板会因 `rating !== undefined` 守卫卸载,但 `noteOpen` 仍为 true,因此 document 级的 Escape 与 pointer-down 监听继续挂着;若该 item 之后经 resync 重新出现,浮层会带着上一次的草稿回来且不重新聚焦 textarea。该窗口只有一次点击或一次 Escape 那么宽,而它可能遮住的保存失败已经有行内回退,因此保持现状。若失败在面板关闭并重开后才到达,不会写入新会话的面板:其草稿已按已存备注重新播种,旧尝试的错误会误标新草稿,而未提交的内容本就不存在——该失败被丢弃而不展示。以及,定位虽然会在滚动、窗口缩放与面板自身尺寸变化时重放,但 jsdom 没有布局,因此真实几何由浏览器场景证明,单测则通过 `ResizeObserver` stub 覆盖其接线。 + +520px 以下仍残留仅来自时钟字符串的窄视口溢出,与本界面无关。仓库没有针对未定义设计 token 的门禁;本次工作中的一次扫描在 `ui-agent-preset`、`ui-conversation`、`ui-jobs`、`ui-settings-plugins` 与 `ui-tool` 中又发现更多,本次未触碰,需要单独的改动处理。 diff --git a/apps/web/tests/message-feedback-layout.e2e.ts b/apps/web/tests/message-feedback-layout.e2e.ts new file mode 100644 index 0000000000..89f20eee79 --- /dev/null +++ b/apps/web/tests/message-feedback-layout.e2e.ts @@ -0,0 +1,336 @@ +// Web e2e scenario: with the feedback note editor open, the assistant IconActions +// row stays one intact line (no wrapping, nothing pushed out), and the note +// editor floats above the transcript in a popover that escapes the conversation +// column's overflow clip and stays inside the viewport. +// +// The hazard this pins: a slot-contributed note editor (260px textarea plus +// Save and Cancel) cannot fit the shared IconActions row at ANY viewport, and an +// inline expansion made the row wider than the column — full-screen desktop +// included — so the branch action and the clock were pushed out of view by later +// flex items. The fix is to not mount the editor in the row at all: it is a +// popover portaled to document.body and fixed-positioned from the note trigger's +// rect, so the row keeps its single 28px line of icons and the trigger, and the +// panel cannot be cropped by the column's overflow because it lives outside it. +// +// The sweep records, per viewport, whether the open editor keeps the actions row +// on one line with zero overflow, whether the panel is outside the column (proof +// it escapes the clip), whether the panel stays inside the viewport (proof the +// clamp works), and whether it sits by its trigger. All relations, no absolute +// pixels: the column width follows the viewport, the sidebar, and the platform's +// scrollbar, so a golden carrying pixels would document the platform, not the +// behavior. +// +// Zero model calls: a settled transcript is cold-seeded, so nothing streams. +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 { + compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-layout', import.meta.url)) +/** + * Committed golden of the popover relations at every stop. Booleans and counts + * only, never absolute coordinates. + */ +const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') +const MODE = webSnapshotMode() +/** Borrowed read-only: this scenario needs any settled assistant message to rate. */ +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const SEED_ID = 'message-feedback-layout-e2e' +/** Viewport widths from full-screen desktop down to a narrow window. */ +const WIDTHS = [1680, 1280, 1024, 900, 700, 600] + +/** One viewport stop: how the row reads with the note editor closed and open, plus the popover's own relations. */ +export interface PopoverMetrics { + /** Viewport width the stop was measured at. */ + width: number + /** The row's scrollable overflow with the note editor closed (natural row width). */ + rowOverflowClosed: number + /** The row's scrollable overflow with the note editor open; must equal the closed value. */ + rowOverflowOpen: number + /** Flex lines the row occupies with the note editor open; the editor must not reflow it. */ + rowLines: number + /** Row items whose right edge escapes the column, editor closed. */ + itemsOutsideColumnClosed: number + /** Row items whose right edge escapes the column, editor open; must equal the closed value. */ + itemsOutsideColumnOpen: number + /** True when the portaled panel is NOT inside the column (escapes its overflow clip). */ + panelOutsideColumn: boolean + /** True when the panel lies fully inside the viewport (the clamp holds). */ + panelWithinViewport: boolean + /** Horizontal separation between the panel's left edge and the note trigger's, in px. */ + panelToTriggerGap: number +} + +/** + * Measure the feedback row (and the open popover, when present) at the current + * viewport. The same reader serves the closed and open readings so the two + * sides differ only by whether the editor is open. + * @param page - the page under test. + * @param width - the viewport width already applied, recorded with the reading. + * @param editorOpen - true to also read the popover's relations; throws if it is absent. + * @returns the stop's relations. + */ +function measurePopover(page: Page, width: number, editorOpen: boolean): Promise { + return page.evaluate(({ viewportWidth, open }) => { + const rated = document.querySelector('button[aria-label="Remove rating"]') + if (rated === null) throw new Error('no rated feedback control in the DOM') + const row = rated.parentElement?.closest('div[class*="actions"]') ?? null + if (row === null) throw new Error('the IconActions row is not an ancestor of the feedback control') + const trigger = row.querySelector('button[aria-haspopup="dialog"]') + if (trigger === null) throw new Error('the note trigger is not in the row') + + /** + * The real flex items of the row. A slot contributor (the feedback strip) + * arrives as a `display: contents` wrapper (the `assistant-actions` slot + * renders inside a transparent `data-slot` div), which reports an all-zero + * rect; a zero box would be miscounted as a phantom flex line. The actual + * items are the boxes inside it. + * @param element - the row whose items to read. + * @returns the real flex-item boxes, in flex/DOM order. + */ + const flexItemBoxes = (element: HTMLElement): DOMRect[] => { + const boxes: DOMRect[] = [] + for (const child of Array.from(element.children)) { + const el = child as HTMLElement + const rect = el.getBoundingClientRect() + if (el.style.display === 'contents') { + boxes.push(...flexItemBoxes(el)) + } else if (rect.height > 0 && rect.width > 0) { + boxes.push(rect) + } + } + return boxes + } + /** + * Group items into flex lines by overlapping vertical extent. + * @param boxes - the row items' boxes, in DOM order. + * @returns the number of distinct lines. + */ + const countFlexLines = (boxes: DOMRect[]): number => { + const centres: number[] = [] + for (const box of boxes) { + const centre = box.top + box.height / 2 + if (!centres.some(known => Math.abs(known - centre) <= box.height / 2)) centres.push(centre) + } + return centres.length + } + + const column = row.closest('[data-conversation-scroll]') + const columnRight = (column?.getBoundingClientRect().left ?? 0) + (column?.clientWidth ?? 0) + const itemRects = flexItemBoxes(row) + // A half-pixel tolerance: subpixel layout puts a contained edge a fraction + // over the boundary on some device scale factors. + const itemsOutsideColumn = itemRects.filter(box => box.right > columnRight + 0.5).length + // The editor is a portal, so the row measures identically whether the + // editor is open or not; the closed/open fields differ by call so the sweep + // can assert a zero delta on them. + const overflow = row.scrollWidth - row.clientWidth + + let builder: { + panelOutsideColumn: boolean + panelWithinViewport: boolean + panelToTriggerGap: number + } + if (!open) { + builder = { panelOutsideColumn: true, panelWithinViewport: true, panelToTriggerGap: 0 } + } else { + const panel = document.body.querySelector('[role="dialog"]') + if (panel === null) throw new Error('the note popover is not open') + const panelBox = panel.getBoundingClientRect() + const triggerBox = trigger.getBoundingClientRect() + const vw = window.innerWidth + const vh = window.innerHeight + builder = { + // The panel portals out of the column, so the clip cannot reach it. + panelOutsideColumn: column === null ? true : !column.contains(panel), + panelWithinViewport: + panelBox.left >= -0.5 + && panelBox.right <= vw + 0.5 + && panelBox.top >= -0.5 + && panelBox.bottom <= vh + 0.5, + // The panel is fixed from the trigger's left, so a zero gap says it is + // anchored; a clamp can only widen it. + panelToTriggerGap: Math.abs(panelBox.left - triggerBox.left), + } + } + + return { + width: viewportWidth, + rowOverflowClosed: overflow, + rowOverflowOpen: overflow, + rowLines: countFlexLines(itemRects), + itemsOutsideColumnClosed: itemsOutsideColumn, + itemsOutsideColumnOpen: itemsOutsideColumn, + ...builder, + } + }, { viewportWidth: width, open: editorOpen }) +} + +/** + * Render the golden body: one line per stop, relations and counts only. The + * row-overflow and outside-column readings are deltas (open minus closed) so + * the golden records that opening the editor leaves the row untouched, not an + * absolute count that many unrelated controls could move. + * @param stops - the measured stops, in sweep order. + * @returns the golden body, without a trailing newline. + */ +function renderGeometry(stops: PopoverMetrics[]): string { + return [ + '# Assistant actions row with the feedback note popover open', + '', + '| viewport | row overflow delta | row lines | items-outside delta ' + + '| panel outside the column | panel within the viewport | panel-to-trigger gap |', + '| --- | --- | --- | --- | --- | --- | --- |', + ...stops.map(stop => `| ${String(stop.width)}px | ${String(stop.rowOverflowOpen - stop.rowOverflowClosed)}px ` + + `| ${String(stop.rowLines)} | ${String(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed)} ` + + `| ${String(stop.panelOutsideColumn)} | ${String(stop.panelWithinViewport)} ` + + `| ${String(stop.panelToTriggerGap)}px |`), + ].join('\n') +} + +describe('web e2e: the feedback note editor floats above the column', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser, 900) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** + * Open the seeded transcript. The first treeitem is the collapsible group + * row; the session itself is the row beneath it. + * @returns nothing. + */ + async function openSeededSession(): Promise { + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 15_000 }) + await sessionRow.click() + } + + /** + * Resize to a viewport and read the row once its width stops moving. The + * frame eases its column tracks, so reading straight after a resize can + * report the previous viewport's relation. + * @param width - viewport width to settle at. + * @param editorOpen - whether the note editor is currently open; reads the popover relations when so. + * @returns the row's (and popover's) readings at that width. + */ + const settleAt = async (width: number, editorOpen: boolean): Promise => { + await page.setViewportSize({ width, height: 900 }) + let previous = -1 + await expect.poll(async () => { + const current = await page.evaluate(() => + document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1) + const settled = current === previous + previous = current + return settled + }, { timeout: 10_000 }).toBe(true) + // The popover is JS-positioned from the trigger rect and re-places on + // resize/scroll, so once the column width stops moving we nudge it to the + // final layout; otherwise the panel can sit at a transient position from + // mid-resize and the anchor reading would be off. + await page.evaluate(() => window.dispatchEvent(new Event('resize'))) + return measurePopover(page, width, editorOpen) + } + + /** + * Rate a message, then for every stop read the row once with the note editor + * closed and once with it open, handing the SAME measured readings to both + * assertions so the golden and the assertions describe one measurement + * rather than two runs that could disagree. + * @returns the stops in {@link WIDTHS} order. + */ + let swept: Promise | undefined + const sweep = (): Promise => { + swept ??= (async () => { + await openSeededSession() + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) + // The controller defers its list read to the first hover or focus, so the + // strip has to be touched before it can be rated. + const like = page.getByRole('button', { name: 'Good response' }).first() + await like.waitFor({ timeout: 30_000 }) + await like.scrollIntoViewIfNeeded() + await like.hover() + await like.click() + await page.getByRole('button', { name: 'Remove rating' }).first() + .waitFor({ timeout: 15_000 }) + const noteTrigger = page.getByRole('button', { name: 'Add a note' }).first() + const stops: PopoverMetrics[] = [] + for (const width of WIDTHS) { + // Reset to the closed baseline at each stop before opening. + if (await noteTrigger.getAttribute('aria-expanded') === 'true') await noteTrigger.click() + const closed = await settleAt(width, false) + await page.getByRole('button', { name: 'Add a note' }).first().click() + await page.getByRole('dialog').waitFor({ timeout: 10_000 }) + const open = await settleAt(width, true) + stops.push({ + width, + rowOverflowClosed: closed.rowOverflowClosed, + rowOverflowOpen: open.rowOverflowOpen, + rowLines: open.rowLines, + itemsOutsideColumnClosed: closed.itemsOutsideColumnClosed, + itemsOutsideColumnOpen: open.itemsOutsideColumnOpen, + panelOutsideColumn: open.panelOutsideColumn, + panelWithinViewport: open.panelWithinViewport, + panelToTriggerGap: open.panelToTriggerGap, + }) + } + return stops + })() + return swept + } + + it('keeps the actions row untouched by the note popover, which stays in the viewport', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout')) + const stops = await sweep() + for (const stop of stops) { + // The popover lives outside the row, so opening it must not change the + // row at all. This is the vacuity guard of the whole redesign: an inline + // editor would widen or reflow the row, pushing the delta off zero. + expect(stop.rowOverflowOpen - stop.rowOverflowClosed, `viewport ${String(stop.width)}`).toBe(0) + expect(stop.itemsOutsideColumnOpen - stop.itemsOutsideColumnClosed, `viewport ${String(stop.width)}`).toBe(0) + // The row is one 28px line; the editor never forces a reflow. + expect(stop.rowLines, `viewport ${String(stop.width)}`).toBe(1) + // The panel escapes the column's overflow clip by living outside it. + expect(stop.panelOutsideColumn, `viewport ${String(stop.width)}`).toBe(true) + // The placement clamps the panel inside the viewport at every width. + expect(stop.panelWithinViewport, `viewport ${String(stop.width)}`).toBe(true) + // The panel stays anchored to its trigger rather than drifting off. + expect(stop.panelToTriggerGap, `viewport ${String(stop.width)}`).toBeLessThanOrEqual(4) + } + expect(tripwire.pageErrors).toEqual([]) + }, 180_000) + + it('matches the committed geometry golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-feedback-layout-golden')) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE) + }, 180_000) + + it('kept the console clean', () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md b/apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md new file mode 100644 index 0000000000..7780a8057a --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-layout/geometry.expected.md @@ -0,0 +1,10 @@ +# Assistant actions row with the feedback note popover open + +| viewport | row overflow delta | row lines | items-outside delta | panel outside the column | panel within the viewport | panel-to-trigger gap | +| --- | --- | --- | --- | --- | --- | --- | +| 1680px | 0px | 1 | 0 | true | true | 0px | +| 1280px | 0px | 1 | 0 | true | true | 0px | +| 1024px | 0px | 1 | 0 | true | true | 0px | +| 900px | 0px | 1 | 0 | true | true | 0px | +| 700px | 0px | 1 | 0 | true | true | 0px | +| 600px | 0px | 1 | 0 | true | true | 0px | diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 6e706c7123..c76eb8aae6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -58,6 +58,7 @@ "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/message-feedback.e2e.ts", + "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index 9c05c4820f..59e5ac81f9 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-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/client/ui-message-feedback/README.md -README.md: 461e87589567eb95b075839d5893cc551e67a035 -README.zh.md: 31f722021ff65f476a6ba1ab0211fd4e091671d2 +README.md: d3bb4e28b95da2fde0d26b7ef83ebca377b4939f +README.zh.md: d823bb9de2b7d39d0bc5d00164b8ce8dffec4a34 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index 461e875895..d3bb4e28b9 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets. +Per-message feedback plugin, browser half: a Like/Dislike pair plus an optional note, contributed as the `feedback` entry (order 10) of the `conversation.chat.assistant-actions` strip. The strip is declared by `ui-conversation` and rendered inside the finalized assistant message's IconActions row, between copy and branch, so the controls inherit that row's chrome and hover behavior. The note editor itself does not sit in that row: it is a `role="dialog"` popover portaled to `document.body` and anchored under its trigger, so the row keeps its single line whether the editor is open or closed and the panel is not clipped by the conversation column. A rating or list-load failure shows inline in the row; a note-save failure shows inside the popover, which stays open so the draft can be corrected. Only finalized messages reach the slot — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. The strip renders once per turn, on the closing assistant message that owns the turn's IconActions row: earlier steps of a multi-step turn produce tool rows rather than a rateable body, so they present no controls even though the Host would accept them as targets. One `MessageFeedbackController` per Session backs every message control in that Session, so a single `messageFeedback.list` read seeds the whole transcript. The read is deferred to the first hover or focus rather than fired on mount, because the controls mount once per settled message in the visible history. diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index 31f722021f..d823bb9de2 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。 +单条消息反馈插件的浏览器侧:一对 Like/Dislike 按钮加一个可选备注,作为 `conversation.chat.assistant-actions` 条带的 `feedback` 条目(order 10)贡献。该条带由 `ui-conversation` 声明,渲染在已定稿助手消息的 IconActions 行内、复制与分支之间,因此控件沿用该行的样式与 hover 行为。备注编辑器本身不在这一行里:它是一个 `role="dialog"` 的浮层,portal 到 `document.body` 并锚定在其触发按钮下方,因此无论编辑器是否打开该行都保持单行,面板也不会被会话列裁掉。评分或列表加载失败在行内展示;备注保存失败在浮层内展示,且面板保持打开以便修正草稿。只有已定稿的消息能到达这个 slot——被中断冻结的部分输出不带 `messageId`,因此也没有反馈控件。该操作栏每个 Turn 渲染一次,位于持有该 Turn IconActions 行的收尾助手消息上:多步骤 Turn 中较早的步骤产出的是工具行而非可评分正文,因此即使 Host 会接受它们作为目标,界面上也不出现控件。 每个 Session 一个 `MessageFeedbackController`,支撑该 Session 内所有消息的控件,因此一次 `messageFeedback.list` 读取即可填充整段对话。该读取延迟到首次 hover 或 focus 才发起,而不是在挂载时触发,因为可见历史中每条已结束的消息都会挂载一次控件。 diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 2b23b7ccc4..032425e34b 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -71,6 +71,7 @@ "@deepseek-ai/cordis": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css index 8f554f2a3b..45055ad559 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css @@ -1,6 +1,9 @@ /* Per-message feedback controls. The rating buttons mirror the shared message - IconActions chrome so the strip reads as one row; the note editor is an - inline expansion anchored to the same row. */ + IconActions chrome so the strip reads as one row. The note editor is a + popover portaled to document.body and fixed from the note trigger's rect, + so it neither competes with the row for inline width nor gets cropped by the + conversation column's overflow clip. Surface recipe follows the Menu card: + r12, inverted hairline border, shadow-lv3. */ .action { display: inline-flex; @@ -47,29 +50,58 @@ cursor: pointer; } -.noteOpen:hover { +.noteOpen:hover, +.noteOpen[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); } -.noteEditor { - display: inline-flex; - align-items: flex-start; - gap: 6px; +/* Portal surface: fixed in the viewport, left/top supplied inline from the + trigger rect. Portaled panels must layer above modal overlays (z 1000). */ +.notePanel { + position: fixed; + z-index: 1100; + box-sizing: border-box; + width: 320px; + max-width: min(360px, calc(100vw - 24px)); + /* The width bound's counterpart. `resize: vertical` on the textarea lets the + panel be dragged taller, and a panel taller than the viewport would push + the placement clamp's upper bound below its own margin, so `top` would go + negative and cut off the panel's head. Both bounds keep the 12px margin + the clamp uses. */ + max-height: calc(100vh - 24px); + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 12px; + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } .noteInput { - width: 260px; + width: 100%; + box-sizing: border-box; padding: 6px 8px; - border: 1px solid var(--dsw-alias-border-secondary); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; - background: var(--dsw-alias-bg-primary); + background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); font: inherit; font-size: 13px; resize: vertical; } +.noteActions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + .noteSave, .noteCancel { height: 28px; @@ -81,8 +113,12 @@ } .noteSave { - background: var(--dsw-alias-interactive-bg-primary); - color: var(--dsw-alias-label-inverse); + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); +} + +.noteSave:hover:not(:disabled) { + background: var(--dsw-alias-button-primary-hover); } .noteSave:disabled { @@ -104,5 +140,5 @@ padding-left: 4px; color: var(--dsw-alias-label-tertiary); font-size: 13px; - line-height: 28px; + line-height: 20px; } diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx index 779f76b12a..db7d50569b 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx @@ -1,23 +1,47 @@ /** * Per-message feedback controls: a Like/Dislike pair plus an optional note. - * Rendered inside the assistant message's IconActions row, so the buttons - * reuse that row's chrome and sit between copy and branch. + * The buttons render inside the assistant message's IconActions row, so they + * reuse that row's chrome and sit between copy and branch. The note editor is + * a popover (portaled to `document.body`) anchored to the note trigger, not an + * inline expansion: a 260px textarea plus buttons cannot fit the row at any + * viewport, and an inline element pushed the branch action and clock out of the + * conversation column. Portaling out of the column also escapes its `overflow` + * clip, so the panel cannot be cropped or detached from the message it annotates. * @module @deepseek-ai/dsh-client-ui-message-feedback/client/MessageFeedbackActions */ -import { useCallback, useEffect, useRef, useState } from 'react' import { - IconDislikeOutline16, IconLikeOutline16, Tooltip, + useCallback, useEffect, useRef, useState, + type CSSProperties, +} from 'react' +import { createPortal } from 'react-dom' +import { + IconDislikeOutline16, IconLikeOutline16, Tooltip, useAnchoredPosition, } from '@deepseek-ai/dsh-client-ui-primitives' import type { MessageFeedbackRating } from '@deepseek-ai/dsh-message-feedback/types' import type { MessageFeedbackActionProps } from './slots.ts' import css from './MessageFeedbackActions.module.css' +/** Safe distance kept between the panel and the viewport edges (the Menu portal margin). */ +const PANEL_MARGIN = 12 + +/** Distance between the trigger's bottom edge and the panel's top. */ +const PANEL_GAP = 4 + +/** + * Unplaced portal panel: hidden but laid out so `offsetWidth` is real for the + * clamp. The explicit insets match `Menu`'s measure style — a `position: fixed` + * element with auto insets otherwise sits at its static position, a different + * origin than the one the first placement measures from. + */ +const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } + /** * One message's feedback controls. * @param props - the owner's message identity, the injected verbs, and the * shared feedback hook. - * @returns the rating buttons, plus the note editor while it is open. + * @returns the rating buttons and the note trigger, with the note editor + * portal-open beneath the trigger while it is open. */ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearNote, useFeedback, t }: MessageFeedbackActionProps) { const item = useFeedback(view => view.items.get(messageId)) @@ -26,7 +50,15 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN const [noteOpen, setNoteOpen] = useState(false) const [draft, setDraft] = useState('') const [pending, setPending] = useState(false) - const [failure, setFailure] = useState(null) + // A rating or load failure surfaces beside the rating buttons, always legible + // whether or not the note popover is open. + const [rowFailure, setRowFailure] = useState(null) + // A note save failure surfaces inside the note popover, where the human is + // looking; it stays open so the draft survives to be corrected. + const [noteFailure, setNoteFailure] = useState(null) + const triggerRef = useRef(null) + const panelRef = useRef(null) + const inputRef = useRef(null) // The controls mount for every settled message in the transcript, so the // Session's feedback is read once on first hover/focus rather than on mount. const seeded = useRef(false) @@ -39,47 +71,158 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN const alive = useRef(true) useEffect(() => () => { alive.current = false }, []) - const settle = useCallback((result: { ok: boolean; error?: { code: string } }) => { + /** Bumped whenever an editing session ends, so a late save can tell it is stale. */ + const noteGeneration = useRef(0) + + /** Current panel open-state, readable from a stale closure via a ref. */ + const noteOpenRef = useRef(false) + useEffect(() => { noteOpenRef.current = noteOpen }, [noteOpen]) + + const errorCopy = useCallback((result: { ok: boolean; error?: { code: string } }) => { + return result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic') + }, [t]) + + const settleRating = useCallback((result: { ok: boolean; error?: { code: string } }) => { if (!alive.current) return setPending(false) - if (result.ok) { - setFailure(null) - return - } - setFailure(result.error?.code === 'version-conflict' ? t('error.conflict') : t('error.generic')) - }, [t]) + setRowFailure(result.ok ? null : errorCopy(result)) + }, [errorCopy]) + + const closeNote = useCallback(() => { + // Ends the editing session, so any save still in flight becomes stale. + noteGeneration.current += 1 + setNoteOpen(false) + }, []) const onRate = useCallback((next: MessageFeedbackRating) => { setPending(true) - setFailure(null) + setRowFailure(null) // The controller decides retract-vs-replace from the committed item, so a // click that lands before the first list read still toggles the stored // value instead of this render's empty view. - setNoteOpen(false) - void toggle(messageId, next).then(settle) - }, [messageId, settle, toggle]) + closeNote() + void toggle(messageId, next).then(settleRating) + }, [closeNote, messageId, settleRating, toggle]) // The rating is a parameter because only the note editor's render site can // prove one is recorded; that removes an unreachable undefined guard here. const onSaveNote = useCallback((current: MessageFeedbackRating) => { const trimmed = draft.trim() setPending(true) - setFailure(null) + setNoteFailure(null) + // A save belongs to the editing session that started it. Closing and + // reopening the panel begins a new one, and a late reply from the old + // session must not act on it: a stale success would shut the panel the + // human just opened, and a stale failure would describe a draft this + // session never sent. + const generation = noteGeneration.current + // What a session reopened before this save commits would be seeded with. + const staleSeed = item?.note ?? '' // An emptied editor removes the note explicitly; `rate` alone preserves a // stored note, so it cannot express deletion. const settled = trimmed.length === 0 ? clearNote(messageId) : rate(messageId, current, trimmed) void settled.then((result) => { - settle(result) - if (result.ok && alive.current) setNoteOpen(false) + if (!alive.current) return + // `pending` tracks the request in flight, not the editing session, so it + // is released either way; all three of like, dislike and Save read + // `disabled={pending}`, and holding it would lock the row until remount. + // Releasing it unconditionally is safe because those three are the only + // mutation entries and each is gated by it, so at most one request is ever + // in flight. A future entry that bypasses the gate would have to bind + // `pending` to the generation instead of clearing it here. + setPending(false) + if (result.ok) { + // Only the session that is still open may act on a success: closing it + // already discarded the draft, and reopening seeded a new one. + if (generation === noteGeneration.current) { + setNoteFailure(null) + setNoteOpen(false) + return + } + // A newer session is open, seeded from the note as it read before this + // save committed. Resync it so the editor shows what is stored and the + // next save cannot overwrite the text that just landed. An edited draft + // is the human's, so it is left alone. + setDraft(draftNow => (draftNow === staleSeed ? trimmed : draftNow)) + return + } + // A failure from the session still on screen belongs in its panel. One + // from an abandoned session is reported only when no new session has + // taken over: the row then carries it, so a save that failed after the + // human walked away is not silently dropped. Writing it into a reopened + // panel instead would label the new draft with the old attempt's error. + // `noteOpenRef` — not the `noteOpen` this closure was created from — is + // read here, because a close+reopen between the save and resolution + // leaves this closure with the panel state from when the save started. + if (generation === noteGeneration.current || !noteOpenRef.current) { + setNoteFailure(errorCopy(result)) + } }) - }, [clearNote, draft, messageId, rate, settle]) + }, [clearNote, draft, errorCopy, item?.note, messageId, noteOpenRef, rate]) - const openNote = useCallback(() => { + // The trigger toggles: while closed it opens the popover (seeding the draft + // with the recorded note), while open it closes it. Toggling closed via the + // trigger also fires the outside/within logic correctly because the trigger + // is inside the panel's "inside" region. + const toggleNote = useCallback(() => { + if (noteOpen) { + closeNote() + return + } setDraft(item?.note ?? '') + // A note-save failure belongs to the editing session that produced it. The + // panel stays open on failure so the draft can be corrected, but once it is + // closed and reopened the draft is reseeded from the stored note, so a + // carried-over error would describe an attempt the new draft never made. + // A failure that arrives after the panel closed is reported in the row, and + // clearing it here is what retires that notice when a new session starts. + setNoteFailure(null) setNoteOpen(true) - }, [item?.note]) + }, [noteOpen, closeNote, item?.note]) + + // Place the portaled panel from the trigger rect before paint and keep it + // with the trigger on scroll/resize, the same anchoring `Menu` uses for its + // portal mode. + const pos = useAnchoredPosition({ + open: noteOpen, + anchorRef: triggerRef, + panelRef, + gap: PANEL_GAP, + margin: PANEL_MARGIN, + }) + + // Focus the input and close on Escape or outside pointer-down while open. + useEffect(() => { + if (!noteOpen) return + inputRef.current?.focus() + const onPointerDown = (e: PointerEvent) => { + if (!(e.target instanceof Node)) return + if (triggerRef.current?.contains(e.target) === true) return + if (panelRef.current?.contains(e.target) === true) return + closeNote() + } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeNote() + } + document.addEventListener('pointerdown', onPointerDown) + document.addEventListener('keydown', onKeyDown) + return () => { + document.removeEventListener('pointerdown', onPointerDown) + document.removeEventListener('keydown', onKeyDown) + } + }, [noteOpen, closeNote]) + + // Return focus to the trigger only when the panel actually closes, not on the + // initial mount (a freshly rendered message with a recorded rating must not + // pull focus into its action row). + const wasOpen = useRef(false) + useEffect(() => { + if (noteOpen) { wasOpen.current = true; return } + if (wasOpen.current) triggerRef.current?.focus() + wasOpen.current = false + }, [noteOpen]) const likeLabel = rating === 'positive' ? t('action.likeActive') : t('action.like') const dislikeLabel = rating === 'negative' ? t('action.dislikeActive') : t('action.dislike') @@ -116,38 +259,66 @@ export function MessageFeedbackActions({ messageId, ensure, rate, toggle, clearN - {rating !== undefined && !noteOpen && ( - )} - {rating !== undefined && noteOpen && ( - + {rowFailure === null && loadFailed && ( + {t('error.load')} + )} + {rowFailure !== null && {rowFailure}} + {/* A note-save failure normally lives inside the panel, beside the buttons + that produced it. Whenever the panel is not on screen it falls back to + the row instead: the rating may have disappeared underneath an open + editor (another client retracts the feedback, a `version-conflict` + reply commits `current: null`, the item goes away), or the human may + have closed the panel before a slow save came back. Either way the row + reports that the save did not land rather than dropping it. */} + {!(rating !== undefined && noteOpen) && noteFailure !== null && ( + {noteFailure} + )} + {rating !== undefined && noteOpen && createPortal( +