From ad3632f12219e9ac2f47722f3651a8df8ee7fa5c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 27 Jul 2026 15:25:33 +0800 Subject: [PATCH 01/16] 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 02/16] 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 03/16] 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 04/16] 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 05/16] 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 06/16] 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 ebcf7d042333e34a8212dbcfc7346af4abf25479 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 14 Aug 2026 16:25:41 +0800 Subject: [PATCH 07/16] 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 6bb79911ba1e666a2d0f0e285249923b87f1bd38 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 18:35:04 +0800 Subject: [PATCH 08/16] 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 09/16] 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 10/16] 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 29de639e998cbd0ebfd0738bfcf051631ef83af3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 21:13:33 +0800 Subject: [PATCH 11/16] =?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 12/16] =?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 fdf6fec5c023b8fda4b5e76592b004e13aa25c9a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 17 Aug 2026 21:25:12 +0800 Subject: [PATCH 13/16] =?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 14/16] =?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 15/16] 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 9620a752c8cdb6e962caaa0808be3745bb6a00ea Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 19 Aug 2026 14:55:26 +0800 Subject: [PATCH 16/16] =?UTF-8?q?refactor(agent-loop):=20=E7=BC=A9?= =?UTF-8?q?=E5=B0=8F=E5=8F=96=E6=B6=88=E5=89=8D=E7=BC=80=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...cancelled-stream-prefix-finalize.i18n.yaml | 4 +- ...-08-10-cancelled-stream-prefix-finalize.md | 4 +- ...-10-cancelled-stream-prefix-finalize.zh.md | 4 +- packages/core/agent-loop/src/agent.ts | 158 +++++++----------- 4 files changed, 71 insertions(+), 99 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 2991c6b1c8..50cdedbe5b 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: 722d5531820efe5640068f11582aef6485f1f9e1 -2026-08-10-cancelled-stream-prefix-finalize.zh.md: a48790fe5883e341148cd82053d4ca9008e4fe81 +2026-08-10-cancelled-stream-prefix-finalize.md: 0cae25b786922fba8204d68ca9c0a669e43d76a0 +2026-08-10-cancelled-stream-prefix-finalize.zh.md: e961ea6a51f74dcc244e4ad8970eae4cbe4c9a6c 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 722d553182..0cae25b786 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,9 +12,9 @@ The model history must contain assistant content that remains visible to the use ## Decision -`ReactLoopAgent.step()` retains the active `BlockAssembler`, logged chunk seqs, and provider route until the attempt commits or fails. Cancellation of an uncommitted attempt appends its delivered prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`. +`ReactLoopAgent.step()` catches cancellation while consuming a model stream, when its `BlockAssembler`, logged chunk seqs, and provider route identify the delivered prefix. It appends that prefix as the step's `assistant/message` with `interrupted: true`, `surfaceOp: 'append'`, and `sourceEventSeqs` containing exactly the logged chunks. The append precedes `step/end` and the aborted `turn/end`. -`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. An attempt ending with an `error` or `aborted` finish is cleared before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed attempt. +`BlockAssembler.interruptedBlocks()` returns closed and open `text` and `reasoning` blocks with non-whitespace content in stream order. It omits tool calls because interruption precedes dispatch and no real result exists; it also omits empty blocks and open unknown block types. An empty result appends no assistant message. Provider `error` and `aborted` finishes leave the stream-consumption scope before `agent/request-error`, so provider failures and cancellation during recovery commit no content from the failed request. Chat and Trajectory Conversation Definitions read `interrupted` from the durable message. Chat renders the Stopped marker, while Trajectory keeps the provider request in the error lifecycle after `step/end` and retains the durable result seq and provenance. Cancellation during tool execution follows the tool scheduler contract because the assistant message has already committed: started calls produce real results, and undispatched calls receive `ABORTED_BEFORE_DISPATCH` results. 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 a48790fe58..e961ea6a51 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,9 +12,9 @@ Status: implemented ## Decision -`ReactLoopAgent.step()` 会保留活跃的 `BlockAssembler`、已记录的分片 seq 和提供方路由,直到尝试提交或失败。取消未提交的尝试时,循环把已送达前缀追加为该 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。 +`ReactLoopAgent.step()` 在消费模型流期间捕捉取消,此时 `BlockAssembler`、已记录的分片 seq 和提供方路由可以确定已送达前缀。循环把该前缀追加为 step 的 `assistant/message`,并设置 `interrupted: true`、`surfaceOp: 'append'` 以及恰好包含已记录分片的 `sourceEventSeqs`。该追加先于 `step/end` 和记录 aborted 的 `turn/end`。 -`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。以 `error` 或 `aborted` finish 结束的尝试会在 `agent/request-error` 前清空,因此提供方故障和恢复期间的取消都不会提交失败尝试的内容。 +`BlockAssembler.interruptedBlocks()` 按流顺序返回内容非空白的已闭合和未闭合 `text` 与 `reasoning` 块。打断先于分派,没有真实工具结果,因此它会省略工具调用,也会省略空块和未闭合的未知块类型。返回结果为空时不追加 assistant 消息。提供方的 `error` 和 `aborted` finish 会在 `agent/request-error` 前离开流消费范围,因此提供方故障和恢复期间的取消都不会提交失败请求的内容。 Chat 和 Trajectory Conversation Definition 从持久消息读取 `interrupted`。Chat 渲染 Stopped 标记,Trajectory 则在 `step/end` 后把提供方请求保持在 error 生命周期,并保留持久结果 seq 和提供方信息。工具执行期间的取消遵循工具调度器约定,因为 assistant 消息已提交:已启动的调用生成真实结果,未分派的调用获得 `ABORTED_BEFORE_DISPATCH` 结果。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 1ba09684c3..3ef1ec7aa4 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -51,14 +51,6 @@ 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 @@ -290,6 +282,8 @@ export class ReactLoopAgent implements Agent { for (const message of decision.messages) { this.session.append('user/message', message, { surfaceOp: 'append' }) } + // max-tokens is sticky: once any step hits the ceiling, later steps + // that complete normally must not downgrade the turn outcome. const stepEnd = await this.step(decision.assembly) // max-tokens stays sticky: a later completed step must not // downgrade the turn outcome. @@ -342,17 +336,13 @@ export class ReactLoopAgent implements Agent { signal.throwIfAborted() const system = renderPrompt(assembly) - // Keep the active attempt until it commits or fails so cancellation can - // preserve the same streamed prefix in durable message history. - 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 } + while (true) { + const { request, preparedCall } = await this.buildRequest( + turn, step, assembly.tools, system, this.session.deriveMessages(), signal, + ) + const assembler = new BlockAssembler() + const chunkSeqs: number[] = [] + try { const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request) signal.throwIfAborted() for await (const chunk of stream) { @@ -361,92 +351,74 @@ export class ReactLoopAgent implements Agent { assembler.push(chunk) } signal.throwIfAborted() - const finish = assembler.finish - if (finish.kind === 'error' || finish.kind === 'aborted') { - // Provider failures commit no assistant content. Clearing before the - // recovery waterfall also prevents a cancellation during retry delay - // from restoring the failed attempt after clients reset its stream. - attempt = undefined - const action = await this.dispatch.waterfall( - 'agent/request-error', { + } catch (error: unknown) { + if (signal.aborted) { + const content = assembler.interruptedBlocks() + if (content.length > 0) { + this.session.append('assistant/message', { 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) + message: createAssistantMessage({ + content, + source: { provider: request.provider, model: request.model }, + }), + interrupted: true, + ...assembler.usage === undefined ? {} : { usage: assembler.usage }, + }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }) } - 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', - { + throw error + } + const finish = assembler.finish + if (finish.kind === 'error' || finish.kind === 'aborted') { + const action = await this.dispatch.waterfall( + 'agent/request-error', { turn, step, - message, - ...assembler.usage === undefined ? {} : { usage: assembler.usage }, + provider: request.provider, + failure: finish.failure, + retryPolicy: preparedCall?.retryPolicy, + signal, }, - { surfaceOp: 'append', sourceEventSeqs: chunkSeqs }, + () => Promise.resolve(undefined), ) - if (finish.kind === 'max-tokens') return { kind: 'max-tokens' } + signal.throwIfAborted() + if (action?.kind !== 'retry') { + throw new LlmError(finish.failure.message, finish.failure.code, finish.failure) + } + continue + } - 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 + 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 } } - /** - * Append a cancelled attempt's delivered text and reasoning as an interrupted - * assistant message. Undispatched tool calls and empty content are omitted; - * the resulting durable history matches the prefix clients rendered. - */ - 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, - interrupted: true, - ...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.