diff --git a/.agents/notes/AGENTS.md b/.agents/notes/AGENTS.md index ea0fa8f42c..e8e1a0ef66 100644 --- a/.agents/notes/AGENTS.md +++ b/.agents/notes/AGENTS.md @@ -2,4 +2,6 @@ Agent Notes are effectively RFCs written by agents: durable proposals and decision records that preserve rationale, alternatives, consequences, and verification contracts. Follow the [documentation standard](../../docs/AGENTS.md) and the [Agent Note contract](README.md). +**Every new Agent Note triggers a supersession check.** Search the active tree for older notes covering the same decision or mechanism, classify any full or partial supersession with [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md), and archive every qualifying implemented triplet in the same PR. Keep partial supersessions active and cross-linked. + Files under [`archived/`](archived/AGENTS.md) are frozen historical snapshots: never edit them or treat them as current authority. diff --git a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml b/.agents/notes/archived/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml similarity index 61% rename from .agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml rename to .agents/notes/archived/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml index 61ecb29ca3..a1e3f9c72c 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-11-tool-schemas-in-prompt-assembly.md: 3643ac3d61be08f629ef0cd0424fef5cb9696c3a -2026-06-11-tool-schemas-in-prompt-assembly.zh.md: 10389fd7c63755e5b00b3c508fd303a541287f2c +2026-06-11-tool-schemas-in-prompt-assembly.md: 4d5251163969127ad9fd8ed0c64416057713dfa8 +2026-06-11-tool-schemas-in-prompt-assembly.zh.md: c04246baa196f50ec1c0dc6f91e9b7b12f67c5e0 diff --git a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md b/.agents/notes/archived/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md similarity index 98% rename from .agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md rename to .agents/notes/archived/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md index 3643ac3d61..4d52511639 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md +++ b/.agents/notes/archived/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md @@ -1,6 +1,7 @@ # Agent Note: Tool schemas are part of the system-prompt assembly Status: implemented +Archived: 2026-07-27 English | [中文](2026-06-11-tool-schemas-in-prompt-assembly.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md b/.agents/notes/archived/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md similarity index 98% rename from .agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md rename to .agents/notes/archived/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md index 10389fd7c6..c04246baa1 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md +++ b/.agents/notes/archived/architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md @@ -1,6 +1,7 @@ # Agent Note: 工具 schema 是系统提示词组装的一部分 Status: implemented +Archived: 2026-07-27 [English](2026-06-11-tool-schemas-in-prompt-assembly.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml b/.agents/notes/archived/architecture/2026-06-20-package-hierarchy.i18n.yaml similarity index 65% rename from .agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml rename to .agents/notes/archived/architecture/2026-06-20-package-hierarchy.i18n.yaml index 03a271a9c3..4608b8452b 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-06-20-package-hierarchy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-20-package-hierarchy.md: 4e05e3487483ab8d710959c1888ec1f5c3b37432 -2026-06-20-package-hierarchy.zh.md: f57704ad082c4961aa48056af1b4b279d2f2c055 +2026-06-20-package-hierarchy.md: 43435279cdf4eec544638a5b36c53b4833b4cbbd +2026-06-20-package-hierarchy.zh.md: a44304d071d165cfe8f112e987160dd37626a58a diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md b/.agents/notes/archived/architecture/2026-06-20-package-hierarchy.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md rename to .agents/notes/archived/architecture/2026-06-20-package-hierarchy.md index 4e05e34874..43435279cd 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/.agents/notes/archived/architecture/2026-06-20-package-hierarchy.md @@ -1,6 +1,7 @@ # Agent Note: Reorganize packages into a modular hierarchy Status: implemented +Archived: 2026-07-27 English | [中文](2026-06-20-package-hierarchy.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md b/.agents/notes/archived/architecture/2026-06-20-package-hierarchy.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md rename to .agents/notes/archived/architecture/2026-06-20-package-hierarchy.zh.md index f57704ad08..a44304d071 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md +++ b/.agents/notes/archived/architecture/2026-06-20-package-hierarchy.zh.md @@ -1,6 +1,7 @@ # Agent Note: 将包重组为模块化层级结构 Status: implemented +Archived: 2026-07-27 [English](2026-06-20-package-hierarchy.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml b/.agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml similarity index 62% rename from .agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml rename to .agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml index e6d8d8570a..a9516a0625 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml +++ b/.agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-result-time-applied-hunk-diffs.md: 55e1612aacd9070ede2f0079c73c30975e1bd5cf -2026-07-02-result-time-applied-hunk-diffs.zh.md: 6fe0032a507af2915bdf79a43578082785411479 +2026-07-02-result-time-applied-hunk-diffs.md: 19b41cc441f5cab299dbeded0a9eae25ab7e9f97 +2026-07-02-result-time-applied-hunk-diffs.zh.md: b77ec75f0f8af615bde3b95d8fb4620fddedbc9c diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md b/.agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md rename to .agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md index 55e1612aac..19b41cc441 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md +++ b/.agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md @@ -1,6 +1,7 @@ # Agent Note: Result-time applied-hunk diffs for file mutations Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-02-result-time-applied-hunk-diffs.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md b/.agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md rename to .agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md index 6fe0032a50..b77ec75f0f 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md +++ b/.agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md @@ -1,6 +1,7 @@ # Agent Note: 结果时刻的 applied-hunk diff 用于文件变更 Status: implemented +Archived: 2026-07-27 [English](2026-07-02-result-time-applied-hunk-diffs.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml b/.agents/notes/archived/feature/2026-06-25-ask-user-question.i18n.yaml similarity index 65% rename from .agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml rename to .agents/notes/archived/feature/2026-06-25-ask-user-question.i18n.yaml index 6a06911100..3506271563 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.i18n.yaml +++ b/.agents/notes/archived/feature/2026-06-25-ask-user-question.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-25-ask-user-question.md: 51b9a67bfc0fdc88d84a9fa51662d02b127f7925 -2026-06-25-ask-user-question.zh.md: 4bd2c2b8664ba085919eb9d4b8c5dee92d4bf72e +2026-06-25-ask-user-question.md: 0d7b2c4d9e8f986366fc56d1123b322333a48262 +2026-06-25-ask-user-question.zh.md: fd5c0d3f339074cccb3f1b3b10109afc06b14774 diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md b/.agents/notes/archived/feature/2026-06-25-ask-user-question.md similarity index 99% rename from .agents/notes/implemented/feature/2026-06-25-ask-user-question.md rename to .agents/notes/archived/feature/2026-06-25-ask-user-question.md index 51b9a67bfc..0d7b2c4d9e 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.md +++ b/.agents/notes/archived/feature/2026-06-25-ask-user-question.md @@ -1,6 +1,7 @@ # Agent Note: Ask-user question capability Status: implemented +Archived: 2026-07-27 English | [中文](2026-06-25-ask-user-question.zh.md) diff --git a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md b/.agents/notes/archived/feature/2026-06-25-ask-user-question.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md rename to .agents/notes/archived/feature/2026-06-25-ask-user-question.zh.md index 4bd2c2b866..fd5c0d3f33 100644 --- a/.agents/notes/implemented/feature/2026-06-25-ask-user-question.zh.md +++ b/.agents/notes/archived/feature/2026-06-25-ask-user-question.zh.md @@ -1,6 +1,7 @@ # Agent Note: ask-user 提问能力 Status: implemented +Archived: 2026-07-27 [English](2026-06-25-ask-user-question.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml b/.agents/notes/archived/feature/2026-07-07-session-prefix.i18n.yaml similarity index 66% rename from .agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml rename to .agents/notes/archived/feature/2026-07-07-session-prefix.i18n.yaml index e189661f67..e8a135a973 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-07-session-prefix.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-07-session-prefix.md: 75113952fc5f3df8da1580d42ed2a385b6135fe8 -2026-07-07-session-prefix.zh.md: e38bf09298296203b275d6d66a62ef17be7c045d +2026-07-07-session-prefix.md: 8b74e956db42631914a64fe81c1581ff7748a955 +2026-07-07-session-prefix.zh.md: c33c4971984f0486ea2302ed739f006ec95a2e5b diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md b/.agents/notes/archived/feature/2026-07-07-session-prefix.md similarity index 90% rename from .agents/notes/implemented/feature/2026-07-07-session-prefix.md rename to .agents/notes/archived/feature/2026-07-07-session-prefix.md index 75113952fc..8b74e956db 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.md +++ b/.agents/notes/archived/feature/2026-07-07-session-prefix.md @@ -1,9 +1,12 @@ # Agent Note: The session prefix — request-only messages in front of the derived history Status: implemented +Archived: 2026-07-28 English | [中文](2026-07-07-session-prefix.zh.md) +The request-only prefix seam described below was later removed by the [unified sourced-message decision](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md). Current producers inject durable sourced `user/message` context at `agent/step`; this record preserves the earlier design and its trade-offs. + ## Problem A plugin often owns a session-stable opener the model must always see — a skills catalog, an AGENTS.md digest, a workspace baseline. Before this seam the harness offered two homes, and both are wrong for that content. The system prompt is one rendered string: message-shaped content (a user-role `` envelope, a multi-message primer) does not fit it, and providers weight conversation messages differently from system text. Durable history (`agent.inject()`, a `context/message` at session start) makes the opener permanent: every `deriveMessages()` consumer replays it, the compaction retention walk owns it, forks bake it in stale, and a resume cannot refresh it — a catalog captured at session birth outlives the world it described. @@ -12,7 +15,7 @@ The obvious third option — let a plugin edit the request's `messages` on the w ## Decision -`agent/session-prefix` is a waterfall on the agent event map ([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, `[mine, ...await next()]`, which yields registration order on the wire). The loop ([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts)) fires it once per loop instance, lazily before the instance's first `agent/pre-step`; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends ([wire order](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header)). +`agent/session-prefix` is a waterfall on the agent event map ([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)): listeners receive a frozen empty seed and return an extension (the canonical contribution is a prepend, `[mine, ...await next()]`, which yields registration order on the wire). The loop ([agent-loop source](../../../../packages/core/agent-loop/src/)) fires it once per loop instance, lazily before the instance's first `agent/pre-step`; the composed list is deep-cloned, deep-frozen, cached on the instance, and placed in front of the ENTIRE derived history — directly after the provider's system slot — on every request the instance sends ([wire order](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header)). Three properties carry the design: diff --git a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md b/.agents/notes/archived/feature/2026-07-07-session-prefix.zh.md similarity index 89% rename from .agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md rename to .agents/notes/archived/feature/2026-07-07-session-prefix.zh.md index e38bf09298..c33c497198 100644 --- a/.agents/notes/implemented/feature/2026-07-07-session-prefix.zh.md +++ b/.agents/notes/archived/feature/2026-07-07-session-prefix.zh.md @@ -1,9 +1,12 @@ # Agent Note: 会话前缀——派生历史之前的仅请求消息 Status: implemented +Archived: 2026-07-28 [English](2026-07-07-session-prefix.md) | 中文 +下文所述的仅请求前缀 seam 后来已被[统一带来源消息的决策](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md)移除。当前的生产方在 `agent/step` 时注入持久的带来源 `user/message` 上下文;本记录保留了早先的设计及其权衡。 + ## 问题 插件经常拥有一段会话级别稳定的开场内容,模型必须始终看到它:技能目录、AGENTS.md 摘要、工作区基线。在引入本 seam 之前,harness 为这类内容提供了两个归属位置,但两者都不合适。系统提示词是一个渲染后的单一字符串:消息形态的内容(user 角色的 `` 信封、多消息引导序列)放不进去,而且提供方对会话消息和系统文本的权重处理不同。持久化历史(`agent.inject()`、会话启动时的 `context/message`)使开场内容变为永久:每个 `deriveMessages()` 消费方都会回放它,压缩(compaction)的保留遍历拥有它,fork 会将其以陈旧状态固化,恢复也无法刷新它——会话诞生时捕获的目录会比它所描述的世界活得更久。 @@ -12,7 +15,7 @@ Status: implemented ## 决策 -`agent/session-prefix` 是 agent 事件映射上的一个 waterfall(瀑布式事件)([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)):监听器接收一个冻结的空种子并返回扩展(规范的贡献方式是前置插入 `[mine, ...await next()]`,在协议格式上产生注册顺序)。agent loop(智能体循环)([`packages/core/agent-loop/src/loop.ts`](../../../../packages/core/agent-loop/src/loop.ts))在每个循环实例中触发一次,惰性地在实例首次 `agent/pre-step` 之前执行;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发出的每个请求中置于整个派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 +`agent/session-prefix` 是 agent 事件映射上的一个 waterfall(瀑布式事件)([`packages/core/agent/src/types.ts`](../../../../packages/core/agent/src/types.ts)):监听器接收一个冻结的空种子并返回扩展(规范的贡献方式是前置插入 `[mine, ...await next()]`,在协议格式上产生注册顺序)。agent loop(智能体循环)([agent-loop 源码](../../../../packages/core/agent-loop/src/))在每个循环实例中触发一次,惰性地在实例首次 `agent/pre-step` 之前执行;组合后的列表被深拷贝、深冻结、缓存在实例上,并在该实例发出的每个请求中置于整个派生历史之前——紧接在提供方的 system 槽位之后([协议格式顺序](../../../../docs/core-data-structures/core.md#the-request-envelope-llmcallconfig-and-the-logged-header))。 三个属性承载了这一设计: diff --git a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml b/.agents/notes/archived/feature/2026-07-08-repeat-tool-guard.i18n.yaml similarity index 65% rename from .agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml rename to .agents/notes/archived/feature/2026-07-08-repeat-tool-guard.i18n.yaml index 95661f953c..3b3dbe1b07 100644 --- a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-08-repeat-tool-guard.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-08-repeat-tool-guard.md: 67ec29c6c9fa38bf1d5935c469f3f71b1119dc3a -2026-07-08-repeat-tool-guard.zh.md: 01037f29810c781c33beee414e50412a0c9b0f89 +2026-07-08-repeat-tool-guard.md: 218ed381c67a62e3af65ebb375d8800f908414cd +2026-07-08-repeat-tool-guard.zh.md: e522908fb08fe0294cdcac034e301febf0f8683f diff --git a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.md b/.agents/notes/archived/feature/2026-07-08-repeat-tool-guard.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.md rename to .agents/notes/archived/feature/2026-07-08-repeat-tool-guard.md index 67ec29c6c9..218ed381c6 100644 --- a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/.agents/notes/archived/feature/2026-07-08-repeat-tool-guard.md @@ -1,6 +1,7 @@ # Agent Note: Repeat-tool-call guard plugin Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-08-repeat-tool-guard.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md b/.agents/notes/archived/feature/2026-07-08-repeat-tool-guard.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md rename to .agents/notes/archived/feature/2026-07-08-repeat-tool-guard.zh.md index 01037f2981..e522908fb0 100644 --- a/.agents/notes/implemented/feature/2026-07-08-repeat-tool-guard.zh.md +++ b/.agents/notes/archived/feature/2026-07-08-repeat-tool-guard.zh.md @@ -1,6 +1,7 @@ # Agent Note: 重复工具调用守卫插件 Status: implemented +Archived: 2026-07-27 [English](2026-07-08-repeat-tool-guard.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml b/.agents/notes/archived/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml similarity index 61% rename from .agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml rename to .agents/notes/archived/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml index db23b997c0..edf7a382a1 100644 --- a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-09-bash-backed-grep-glob-discovery.md: 9c25afb44885ca2519c5d74a3d721b34fe3561de -2026-07-09-bash-backed-grep-glob-discovery.zh.md: d0dee8e49e0a500c87afbd59423fb65416c1dcc8 +2026-07-09-bash-backed-grep-glob-discovery.md: 63dacd97443e0dfa44108fec42b5f1cf1640c96f +2026-07-09-bash-backed-grep-glob-discovery.zh.md: 325e27c54ed975522819053863cc57705991fbdb diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/.agents/notes/archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md rename to .agents/notes/archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md index 9c25afb448..63dacd9744 100644 --- a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md +++ b/.agents/notes/archived/feature/2026-07-09-bash-backed-grep-glob-discovery.md @@ -1,6 +1,7 @@ # Agent Note: Bash-backed grep and glob discovery tools Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-09-bash-backed-grep-glob-discovery.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md b/.agents/notes/archived/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md rename to .agents/notes/archived/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md index d0dee8e49e..325e27c54e 100644 --- a/.agents/notes/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md +++ b/.agents/notes/archived/feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md @@ -1,6 +1,7 @@ # Agent Note: 由 Bash 支持的 grep 与 glob 发现工具 Status: implemented +Archived: 2026-07-27 [English](2026-07-09-bash-backed-grep-glob-discovery.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-10-session-query-service.i18n.yaml b/.agents/notes/archived/feature/2026-07-10-session-query-service.i18n.yaml similarity index 64% rename from .agents/notes/implemented/feature/2026-07-10-session-query-service.i18n.yaml rename to .agents/notes/archived/feature/2026-07-10-session-query-service.i18n.yaml index b93367f1c4..c918f96bea 100644 --- a/.agents/notes/implemented/feature/2026-07-10-session-query-service.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-10-session-query-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-10-session-query-service.md: 42d12fe2c5e34e71a6166816857b9ced52a61a95 -2026-07-10-session-query-service.zh.md: 2c8d322ca6099db1c8ddbb8a02efbc8729e83dbf +2026-07-10-session-query-service.md: 135385a94a8368af93efeb3937d87c86c129915a +2026-07-10-session-query-service.zh.md: 283f0be3fb40a07d494f55aea94f8222be521d9f diff --git a/.agents/notes/implemented/feature/2026-07-10-session-query-service.md b/.agents/notes/archived/feature/2026-07-10-session-query-service.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-10-session-query-service.md rename to .agents/notes/archived/feature/2026-07-10-session-query-service.md index 42d12fe2c5..135385a94a 100644 --- a/.agents/notes/implemented/feature/2026-07-10-session-query-service.md +++ b/.agents/notes/archived/feature/2026-07-10-session-query-service.md @@ -1,6 +1,7 @@ # Agent Note: Exact session query service Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-10-session-query-service.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-10-session-query-service.zh.md b/.agents/notes/archived/feature/2026-07-10-session-query-service.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-10-session-query-service.zh.md rename to .agents/notes/archived/feature/2026-07-10-session-query-service.zh.md index 2c8d322ca6..283f0be3fb 100644 --- a/.agents/notes/implemented/feature/2026-07-10-session-query-service.zh.md +++ b/.agents/notes/archived/feature/2026-07-10-session-query-service.zh.md @@ -1,6 +1,7 @@ # Agent Note: 精确会话查询服务 Status: implemented +Archived: 2026-07-27 [English](2026-07-10-session-query-service.md) | 中文 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml b/.agents/notes/archived/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml similarity index 60% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml rename to .agents/notes/archived/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml index 233e1ce72a..7e7712948d 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml +++ b/.agents/notes/archived/feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-26-code-mode-trajectory-waterfall-spans.md: fe4dcc25dbf211cf69e0d33937cf87a7482852e2 -2026-07-26-code-mode-trajectory-waterfall-spans.zh.md: aaae06b1fca1b5587d06aa7704adec421d2b2c27 +2026-07-26-code-mode-trajectory-waterfall-spans.md: 2e158878faff29aa1ccd03b9b07a370b10cbb4f8 +2026-07-26-code-mode-trajectory-waterfall-spans.zh.md: 462968c9dae66880778b7a5e871a54983402e221 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md b/.agents/notes/archived/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md rename to .agents/notes/archived/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md index fe4dcc25db..2e158878fa 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md +++ b/.agents/notes/archived/feature/2026-07-26-code-mode-trajectory-waterfall-spans.md @@ -1,6 +1,7 @@ # Agent Note: Code Mode sub-calls in the trajectory and waterfall views Status: implemented +Archived: 2026-07-28 English | [中文](2026-07-26-code-mode-trajectory-waterfall-spans.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md b/.agents/notes/archived/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md similarity index 97% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md rename to .agents/notes/archived/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md index aaae06b1fc..462968c9da 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md +++ b/.agents/notes/archived/feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md @@ -1,6 +1,7 @@ -# Agent Note:trajectory 与 waterfall 视图中的 Code Mode 子调用 +# Agent Note: trajectory 与 waterfall 视图中的 Code Mode 子调用 Status: implemented +Archived: 2026-07-28 [English](2026-07-26-code-mode-trajectory-waterfall-spans.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-07-27-user-message-icon-actions.i18n.yaml b/.agents/notes/archived/feature/2026-07-27-user-message-icon-actions.i18n.yaml new file mode 100644 index 0000000000..49220ee07d --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-27-user-message-icon-actions.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 +2026-07-27-user-message-icon-actions.md: 3a6944143972e8e7f6dcc59033c933d112708b63 +2026-07-27-user-message-icon-actions.zh.md: da80bd9242940da56271c2639114b78e43cd1041 diff --git a/.agents/notes/archived/feature/2026-07-27-user-message-icon-actions.md b/.agents/notes/archived/feature/2026-07-27-user-message-icon-actions.md new file mode 100644 index 0000000000..3a69441439 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-27-user-message-icon-actions.md @@ -0,0 +1,28 @@ +# Agent Note: User-message IconActions under the bubble + +Status: implemented +Archived: 2026-07-27 + +English | [中文](2026-07-27-user-message-icon-actions.zh.md) + +## Problem + +The chat user bubble had no under-bubble action chrome. The Harness design (figma `User_Bubble/message_container`) shows three IconActions — copy, branch in new chat, and edit — right-aligned under the bubble, matching the product action-bar pattern used elsewhere. + +## Decision + +`MessageItem` owns the actions for `kind: 'user'` only. Layout is a column (`align-items: flex-end`, 6px gap): bubble, then a 28px action row with 10px gaps and 28px circular icon buttons (`IconCopyOutline16`, `IconBranchOutline16`, `IconEditOutline16`). Tooltips carry Chinese labels. Actions stay visible by default; `@media (hover: hover)` hides them until the row is hovered or focus-within, so touch / `hover: none` devices keep discoverable controls (opacity alone still hit-tests). + +Copy writes the bubble's joined text blocks to the clipboard (`navigator.clipboard.writeText`, with an `execCommand` fallback). Branch and edit are present chrome with no handlers yet — they reserve the design seats without inventing session-fork or edit-resubmit behavior. + +Steering bubbles keep the badge-only form and do not show these actions. + +## Alternatives considered + +**Wire branch/edit to real session fork and draft-edit now.** Rejected for this change: those product flows are not specified; shipping inert buttons matches the requested scope and avoids half-built mutation paths. + +**Always hide with `opacity: 0` outside hover.** Rejected for touch: without `@media (hover: hover)`, idle opacity still hit-tests while looking empty. Hover-capable pointers keep the fade; others keep the actions visible. + +## Consequences + +User messages expose copy immediately; branch/edit remain clickable stubs until a later decision owns their behavior. Tests pin the three buttons, copy payload, and steering exclusion. diff --git a/.agents/notes/archived/feature/2026-07-27-user-message-icon-actions.zh.md b/.agents/notes/archived/feature/2026-07-27-user-message-icon-actions.zh.md new file mode 100644 index 0000000000..da80bd9242 --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-27-user-message-icon-actions.zh.md @@ -0,0 +1,28 @@ +# Agent Note: 用户消息气泡下方的 IconActions + +Status: implemented +Archived: 2026-07-27 + +[English](2026-07-27-user-message-icon-actions.md) | 中文 + +## 问题 + +聊天用户气泡下方没有操作栏。Harness 设计稿(figma `User_Bubble/message_container`)在气泡下方右对齐展示三个 IconActions——复制、在新对话中分支、编辑——与产品其他位置使用的操作栏模式一致。 + +## 决策 + +仅当 `kind: 'user'` 时,`MessageItem` 拥有这些操作。布局为纵向列(`align-items: flex-end`,间距 6px):先是气泡,再是高度 28px 的操作行;行内间距 10px,圆形图标按钮尺寸为 28px(`IconCopyOutline16`、`IconBranchOutline16`、`IconEditOutline16`)。Tooltip 承载中文标签。操作默认保持可见;`@media (hover: hover)` 下在悬停或 focus-within 前隐藏,以便触摸/`hover: none` 设备仍能发现控件(仅靠 opacity 仍会命中测试)。 + +复制将气泡内拼接后的文本块写入剪贴板(`navigator.clipboard.writeText`,并以 `execCommand` 作为回退)。分支与编辑目前仅有外观、尚无处理函数——它们预留设计席位,但不发明会话 fork 或编辑重提交流程。 + +steering(中途引导)气泡保持仅徽章形态,不展示这些操作。 + +## 考虑过的替代方案 + +**现在就把分支/编辑接到真实的会话 fork 与草稿编辑。**本次变更不予采纳:这些产品流程尚未定稿;交付无行为按钮符合请求范围,也避免半成品的变更路径。 + +**在悬停外始终以 `opacity: 0` 隐藏。**因触摸不予采纳:若无 `@media (hover: hover)`,空闲 opacity 看起来空白但仍会命中测试。具备悬停能力的指针保留淡入;其他设备保持操作可见。 + +## 后果 + +用户消息立即可用复制;分支/编辑仍为可点击的占位,直至后续决策明确其行为。测试钉死三个按钮、复制载荷,以及对 steering 的排除。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 0ca8c4d9bd..78c51b882f 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -4,9 +4,18 @@ "architecture/2026-06-11-custom-schema-dsl.i18n.yaml": "sha256:f05d94c11762e506183044ddb1494a2b200ca16999ef3cef51c7b3a324eec945", "architecture/2026-06-11-custom-schema-dsl.md": "sha256:71286f2676f8b47d0bd56c6cc43cf8102946e6d195942860a5810b9c534d2b2b", "architecture/2026-06-11-custom-schema-dsl.zh.md": "sha256:999ff59565a4459184a644c4de6ef98c1bb1a174712e529f5c8417342abdd437", + "architecture/2026-06-11-tool-schemas-in-prompt-assembly.i18n.yaml": "sha256:37230a2f5b9dbe160b4f36b6906065637846261cbef380c3e2b777cf0d8e9013", + "architecture/2026-06-11-tool-schemas-in-prompt-assembly.md": "sha256:6f7b7f15f53f857ccb8477b3fdf65bf2de3a6f96acd93049c03ce4aff1629538", + "architecture/2026-06-11-tool-schemas-in-prompt-assembly.zh.md": "sha256:fd5ddfc53f8a1c4afa86599c858c2ad27858166e676754ee6e2ce0881f55cef7", "architecture/2026-06-20-extract-example-app-packages.i18n.yaml": "sha256:d99b612cc1051c86d883d74737c72e921735e7a28e0b5e6351d3870c664bdcc4", "architecture/2026-06-20-extract-example-app-packages.md": "sha256:9c7aca3a1e9a1ccc3729961663bc649b90076e671cae23e3db8203305983ccce", "architecture/2026-06-20-extract-example-app-packages.zh.md": "sha256:19bd50232d9f25d35aa3f9dc72d9af0df457dd0eaca8b982d5aa625e5b95bcff", + "architecture/2026-06-20-package-hierarchy.i18n.yaml": "sha256:02b9c383c0fc4e9d6cdd2d28b70b73d02ed913861834db8e2a3111ad82e05dee", + "architecture/2026-06-20-package-hierarchy.md": "sha256:a0c6b3022e27f6106996b7aaf5d087b3e3f66f24f7b5fd7b7c12f172ab5dc2f4", + "architecture/2026-06-20-package-hierarchy.zh.md": "sha256:40eec478cc5ff8ae76996e567d1f58309ed71b3bf0068ee06b088deae283512a", + "architecture/2026-07-02-result-time-applied-hunk-diffs.i18n.yaml": "sha256:e26441a73c76fb4217d7458c2772d0cefc09eaef638ea9d302ebcf5d65c42c18", + "architecture/2026-07-02-result-time-applied-hunk-diffs.md": "sha256:537f57877cc2e86b2d3a8a482043db8d7a51880159b1077c5a45b89cd5d0114c", + "architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md": "sha256:7661634cb816caea859287327e37c5536381ee7ad9c77e320ff9b8bb7a7e62ce", "architecture/2026-07-03-filesystem-directory-listing-seam.i18n.yaml": "sha256:636a822f3240e0401cdddad6a21f3454af1c1593fff14d4c9ce6613495f7dac1", "architecture/2026-07-03-filesystem-directory-listing-seam.md": "sha256:809a3c79f4d602607e8fa93aafd1ebccf4fae50c31f1fb1b1e386bb7ad089153", "architecture/2026-07-03-filesystem-directory-listing-seam.zh.md": "sha256:13735cd4c9fe990e6df3b028d6da01da89e94fde454dc0e968e517151cbd4281", @@ -40,12 +49,27 @@ "feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml": "sha256:79592f96bb25713d01865f37972a6919b2bfb3b66368df0f275bbd59d09ebcf6", "feature/2026-06-18-acp-terminal-and-tool-rendering.md": "sha256:946d0c580705ef2e7c7ac1897ada074e72f2ec4209c1e7531b6eccf116e9aecc", "feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md": "sha256:fd815817925a038f79b52b6fab43abdb2d655db3ec07974ce1320ea3674f2afc", + "feature/2026-06-25-ask-user-question.i18n.yaml": "sha256:8ed9d420908e79650a427c82970407c0b8fd49e00e129d2bd0bf3ff3e0c63ed4", + "feature/2026-06-25-ask-user-question.md": "sha256:d9ecff7baec5ca59c61822f7a41e719c17558044b8d23a30a8573e794ed828b7", + "feature/2026-06-25-ask-user-question.zh.md": "sha256:bdeb4869f880d6083563b4e6aefaa601d2fd1de7d46f3a7fa6e0bed376bc34ee", "feature/2026-06-30-subagent-observe-enrich.i18n.yaml": "sha256:08c2478ba394429f46c1e87a9f055e88704a9000e5d250d5600c0c85124cb17f", "feature/2026-06-30-subagent-observe-enrich.md": "sha256:0630975c3e325975a932f58a65a178b79c624dc56ebd29e288e96f5a189cfbfa", "feature/2026-06-30-subagent-observe-enrich.zh.md": "sha256:b9fbb44a7d81f4063faf3baaf97c382a2f5106be533feb4de792ee57b766c1a4", "feature/2026-07-07-plan-mode.i18n.yaml": "sha256:c59b6a6c218d741cdef8edf625f1d015e409a39411fa64e65200fdebb1c49394", "feature/2026-07-07-plan-mode.md": "sha256:7bf1bb8e826edf68f0ec919dfd4f66955b935b46b4400d7de85fac3e4663edbc", "feature/2026-07-07-plan-mode.zh.md": "sha256:5b08cbcd8023f26744e481386177dd0e82423e8b0032829d0dbc22a92cced0cd", + "feature/2026-07-07-session-prefix.i18n.yaml": "sha256:943c50f778521f1800009cebed56d9ec192fda23b5ee9025a4f2b0e4dd8bd729", + "feature/2026-07-07-session-prefix.md": "sha256:1cfd8e46467111b671b0bd9c2370ef07316ff9b2846fecc0cd29389686c6bbf8", + "feature/2026-07-07-session-prefix.zh.md": "sha256:9a15bbed3a53db84ac12e3dfb93b00bf93dabe6b9e39bebfdfc04e00ba8ff135", + "feature/2026-07-08-repeat-tool-guard.i18n.yaml": "sha256:e4382e8d25f23d728f1869c5195723698842dfe5422443a22f21fa8ae9b2c63c", + "feature/2026-07-08-repeat-tool-guard.md": "sha256:95df2c423624c5c56e0b3bb76ced49e91878c7e5096cfef0faf4b20203d8a2aa", + "feature/2026-07-08-repeat-tool-guard.zh.md": "sha256:cbe63d163aa19fc5a63233b05c8f6ed9a8a0ca215cf6f337a3c1e8e48af11b28", + "feature/2026-07-09-bash-backed-grep-glob-discovery.i18n.yaml": "sha256:8eb6ec1312c55b93c2d51c6e54585d6f4d5bf6db444fb9c336681848676e3638", + "feature/2026-07-09-bash-backed-grep-glob-discovery.md": "sha256:908521563012a17d312de664f380444aeafffd401d2200ec97e7bf004bac9ee7", + "feature/2026-07-09-bash-backed-grep-glob-discovery.zh.md": "sha256:52eec49e454357a7e0c3f27c8aaa63840b5822b5d103f49ed48a5c4f25081b00", + "feature/2026-07-10-session-query-service.i18n.yaml": "sha256:5c3f2ce6db1add66b9cdb82d3d115dde0f68762810bdbf7558070b9ff98c371e", + "feature/2026-07-10-session-query-service.md": "sha256:2d7cdb2629856eb16679c576c3d54b9f0468456ddc5be1cbaa4008d4f353033d", + "feature/2026-07-10-session-query-service.zh.md": "sha256:48d5a2d5f679bad90124c19a2b7a563f5d6551274c2bef9b3ce3eb9612ba0649", "feature/2026-07-14-time-context-plugin.i18n.yaml": "sha256:670c093817c77e093562e02f43984d42ed44ebcced7c91d09366839e412d05e1", "feature/2026-07-14-time-context-plugin.md": "sha256:618b121da38a8b610bcadaecf121ca823b2c8c13598c012b350c214b82fd238f", "feature/2026-07-14-time-context-plugin.zh.md": "sha256:1e9eee8ba427a6f2ee08c79e2fcb33c0948e67a80758fdf9f8c9f7dff9aea361", @@ -91,24 +115,54 @@ "feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml": "sha256:978638cbf18bc6dce9fea0817654f41cc307f99004a637b85a63ae2208fe9095", "feature/2026-07-24-new-session-clears-to-empty-state.md": "sha256:b6b71d3883a167056070713e3dffb5046de953bdd218074d17c88e7690e03d83", "feature/2026-07-24-new-session-clears-to-empty-state.zh.md": "sha256:82a80b48337487029acd05a0137d268f0850f46801fa44a0e62733cacd00d5e9", + "feature/2026-07-26-code-mode-trajectory-waterfall-spans.i18n.yaml": "sha256:916525fdc3a12061928380fd8f7b81cd9763a7873663aad564843fccab0ccedc", + "feature/2026-07-26-code-mode-trajectory-waterfall-spans.md": "sha256:a822963e4c34c9737681d6d70d8167731d4350e66ef7f356684ad096c04fa7ab", + "feature/2026-07-26-code-mode-trajectory-waterfall-spans.zh.md": "sha256:ef01163adf1245f75cc8291db70d389314dee43b6bff958a9da49b097531218d", + "feature/2026-07-27-user-message-icon-actions.i18n.yaml": "sha256:b33e480f19ec58c8c60417a6c03999953d463ca54606a5ac80ec528edf57c49b", + "feature/2026-07-27-user-message-icon-actions.md": "sha256:b6332e67c6dad0a3fcdb597cec9e4dc32b44ad33665f39c1a50501cf38d3f5ad", + "feature/2026-07-27-user-message-icon-actions.zh.md": "sha256:0fc824eac66a18063f7098e1c395c09b580d5a30b96f2b856608c084212c2ac2", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", + "process/2026-06-11-tsdown-over-dumble.i18n.yaml": "sha256:22791adb84a4b6c545173d4f1708eea51151d57e426d875e0e5423be9b6e0212", + "process/2026-06-11-tsdown-over-dumble.md": "sha256:8d3c35dddd8869cc3361059dfe4b7b8ab6716d29dda232c97c2f37e92c841dc0", + "process/2026-06-11-tsdown-over-dumble.zh.md": "sha256:cf11c651c13f5ffef5474e7795006ba3653c5eb08eae75a879be6499353455dc", "process/2026-07-03-documentation-graph-atlas.i18n.yaml": "sha256:b1e1ed4b7865d87f939dbf8c94c0ea1069fdf7af6fa68f695e6c9d6eccbeb123", "process/2026-07-03-documentation-graph-atlas.md": "sha256:b62e92bb12123bfa4c4dac806f584aabb6b60af4c5a6a4ab88f84bb9153e766d", "process/2026-07-03-documentation-graph-atlas.zh.md": "sha256:3485ede4a5e695643bcf9e744a62f8914cff788ae35717dac5eb6bf77e0d65cf", + "process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml": "sha256:92ab8aca8856324acb383927d4500d2f2b3b8ad64d24bd855632035a52671032", + "process/2026-07-04-cordis-jsdoc-completeness-gate.md": "sha256:a9404acbfeaa83f032d17414627b2cce5e280cad8651c6ff078a2ea6af458285", + "process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md": "sha256:e8f4737ecc0925e3dcd60f8319d9cd0fd24de5c7468e1ede4438748d3ad378a1", + "process/2026-07-04-persistence-log-catalog.i18n.yaml": "sha256:23119a0769808279d7caef9e84934cc53b6a86580b703c205fa47ad181a2bf76", + "process/2026-07-04-persistence-log-catalog.md": "sha256:fbb44218b77fd92f7b0cc7af2559200547018ceb5c34aca560371227c8bf5898", + "process/2026-07-04-persistence-log-catalog.zh.md": "sha256:a39ee31bec043e97ad6b7e8433d4c1d24200642049e371ac0167a2092a6e5286", + "process/2026-07-06-generated-config-catalog.i18n.yaml": "sha256:48e64ccfc7622559a9b964e2741ad82a9fa37243a0d5c472f0482d9c748d7739", + "process/2026-07-06-generated-config-catalog.md": "sha256:c8654a4e084bfbf4f7d8eb78239dc1d5d62e1384b2aed309fa2526a4d6de0f04", + "process/2026-07-06-generated-config-catalog.zh.md": "sha256:5ab8d964f12e0cf9a04251e988f0b2a114d7ae8ea7ed337c00f625213cb1dc94", "process/2026-07-06-parallel-github-ci-gates.i18n.yaml": "sha256:0f6ece268d9a51bc20cb8eb929f26d8838761603a64eb08dc24521198f10da36", "process/2026-07-06-parallel-github-ci-gates.md": "sha256:6249bd7396ae7f2d0dc671879ce21cefab33a47ace6ef17a25a70e8650b815af", "process/2026-07-06-parallel-github-ci-gates.zh.md": "sha256:cf7edb9bcf97ab1d4e452330c0df0b127a664509ec3f11597ace3eabeb663a5b", + "process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml": "sha256:0a2e97095be592e2c603f23d9b3b8959c4556b5636fa2c6e4539be18cfcc7a6c", + "process/2026-07-17-run-ci-examples-from-built-lib.md": "sha256:d7e8901c67e9850b05cef65132f193b8be566be40d0f54b28b11043871955263", + "process/2026-07-17-run-ci-examples-from-built-lib.zh.md": "sha256:9bb720b65a0d5933b5804dfbe0104c831a26695687b7f1a72f4ed4cfb4bd4415", + "process/2026-07-20-generated-cordis-core-api.i18n.yaml": "sha256:4405f216d4beefdbb33f521dc8126be4f8d3a664abfed3a4cdad04fef84ab0f9", + "process/2026-07-20-generated-cordis-core-api.md": "sha256:eed4dde36984f584c211bd00fa88ce4aef9ee296a0814b46a8d759f953a90660", + "process/2026-07-20-generated-cordis-core-api.zh.md": "sha256:74f4316abc78500097f1b25bac06b460cb20a7dd3d5f323da875cc0575148991", "process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml": "sha256:1dbe70d21dd510bec4f2f56ae39d0fdc7290d5648280ca0b67224cd23b3a02a8", "process/2026-07-21-doc-sync-through-gate-scheduler.md": "sha256:b3eb3f2395ad8f1b77f44aa3fdac79856e5d0b6b4873560d0cc87b63de2ea2e0", "process/2026-07-21-doc-sync-through-gate-scheduler.zh.md": "sha256:e262e02c3d08057b83b0d29281eadb92723f0fe5b3f54424528f47be137bc760", + "process/2026-07-22-cordis-tutorial-docs.i18n.yaml": "sha256:4a7d80b72fd1d86379937250817459324a71b03385ebd35cd6e5a7c8cdc32dd9", + "process/2026-07-22-cordis-tutorial-docs.md": "sha256:a4332b4357eb1f6f0f576e4f87c0d5697c12c8486dc2d92785556a54f2c7af90", + "process/2026-07-22-cordis-tutorial-docs.zh.md": "sha256:dd9edc0e37abbf6f6975fdf0df5d498bc3589acf23bb8b31d72e3216fd930a62", "process/2026-07-22-installer-in-repo-skip-clone.i18n.yaml": "sha256:677aa91c3ccd9eda8a658b10410699ac608d3891d2fa32529898a3432fb56660", "process/2026-07-22-installer-in-repo-skip-clone.md": "sha256:4e30c0dd5429db33638a91a30afdd3386ac1a4705bd259a5eef325b5f86cced8", "process/2026-07-22-installer-in-repo-skip-clone.zh.md": "sha256:1d93c99f5a8d56077e766242c33245621626be55cf481d01c83bb5cbbe9a74d7", "process/2026-07-23-browser-demo-gif-recording.i18n.yaml": "sha256:808ccdda39e540645b440e40a2124baed737b98636265d8f6d8cf036a70f0d50", "process/2026-07-23-browser-demo-gif-recording.md": "sha256:4d3a3dc829c75b66f4f57a6a763b4b9562ce10efb90a19308142f598c5ea8524", "process/2026-07-23-browser-demo-gif-recording.zh.md": "sha256:409e5d31ea87f35c5227fcd1bd105167f580a8a24d29a223ce390b29eb31639a", + "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml": "sha256:89b6dc255cb0dd9d97ae6f34f37d185412d4260d7bf2f87d6b25e340f01dc26e", + "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md": "sha256:1742e09435ade4a09349c8843eb381e870a2be2d51b74449f08260422c8096b3", + "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md": "sha256:220bd53a88617b09ee8970627c46540fa8951724b102acfe296a7ed7d0f7b5fe", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", @@ -118,6 +172,9 @@ "simplification/2026-06-20-prune-dead-seam-methods.i18n.yaml": "sha256:0594648368c942f429599ac0ff5977d62c89c70a31d4bdbac61b0a30fe15ef3b", "simplification/2026-06-20-prune-dead-seam-methods.md": "sha256:fd3b0eaf600e178eeeef0c6cedc71f2382878733c557f1915d3b47f74a1d0d6d", "simplification/2026-06-20-prune-dead-seam-methods.zh.md": "sha256:4f5feef9331e3a1346bc362ffb39cfa373db2c609041bfeee6d88a10392464b1", + "simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml": "sha256:eef600eafd70a576b2ac16a74f5dd5010ee601376008a90be10f1da56d746cee", + "simplification/2026-07-02-remove-stream-chunk-mirror.md": "sha256:0c22a896260c6eb8991cc8babd8172f7b6889fc41bb891d748f34650b3eee5ec", + "simplification/2026-07-02-remove-stream-chunk-mirror.zh.md": "sha256:ae7a3c2450b16fdf2f8da9e1f83cd987bf387671ecc8ebb76d2e7541695e7ee1", "simplification/2026-07-04-drop-inert-request-knobs.i18n.yaml": "sha256:e4c992a27ae0e37e5ef663c2cddf55eefe20387fd6103bebf655834d8e75e9db", "simplification/2026-07-04-drop-inert-request-knobs.md": "sha256:8735c2b868a85b13235e0491a0fa7b9570dd090eef5170324fc5e93782687b67", "simplification/2026-07-04-drop-inert-request-knobs.zh.md": "sha256:78b243f5d580f2a6fbbdb7d26574295d6ed74feb8d9bba34bbcdf4aa87624b5c", @@ -163,6 +220,12 @@ "simplification/2026-07-21-tui-todo-write-opt-in.i18n.yaml": "sha256:633975e45444f179e5fcd258d3c4bce924975583505fa97f18cff21861a88ca2", "simplification/2026-07-21-tui-todo-write-opt-in.md": "sha256:7c4c0818f5cb5b1a506dabb71a56b7d79b811e4b912d492865f1404f4d1ece99", "simplification/2026-07-21-tui-todo-write-opt-in.zh.md": "sha256:2c121b8ea03182f7854e7d834b07967fdb6790af6a2a38c1d24bb0ca968496ba", + "simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml": "sha256:b02b34f76599eef44ef6cbacac36345716bed52eafe8f30e5300d654a93eecbb", + "simplification/2026-07-22-tui-titles-from-session-title-service.md": "sha256:c84b34c2c4700f78e3060e64f49f16625bab002e8211dcd6fce431e7fd076392", + "simplification/2026-07-22-tui-titles-from-session-title-service.zh.md": "sha256:99df66f452004df84efb215e3b58bf73e39af0538ddf038bbed4ac6b65c630ea", + "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml": "sha256:7acf002ea8c1533f052c7bfc0c4e3da013ecf43c5872866a3ee4a8c2691c5e33", + "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md": "sha256:f18a913096b7defd2192c4bac888a33f68075c3662703a0e28a6146897d17777", + "simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md": "sha256:ff48a37673c97059536fe5b61aff746133eac682145550badb049eb5c83b097c", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.i18n.yaml": "sha256:4177012c0821a8c22499852ecdf096af56d7263cb91c5d9d1bcd552cc26a3e00", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.md": "sha256:45234e7cc04b6010c6141f8d5924c04547300098f96262d423c50108e7c7011a", "testing/2026-06-20-remove-redundant-snapshot-log-expected-output.zh.md": "sha256:15e5a4ad3dee0bb711480cabe45cd97ec37bbdba19c2c2b47d1e9c203b07a48b", @@ -177,6 +240,9 @@ "testing/2026-07-04-single-source-acp-replay-config.zh.md": "sha256:bed4dcd236a07192dd3de6c76e4a5c47dd5ec35963ce830bd5521bbb41d3f3a3", "testing/2026-07-06-pin-request-header-content-in-one-scenario.i18n.yaml": "sha256:4f3ebae0faea8a38ffe0d5291a33b3bcf99ed723f8e0cc5cccecbedbf4fb9ce9", "testing/2026-07-06-pin-request-header-content-in-one-scenario.md": "sha256:050bf8044ce22a27a0f57b5cef84ccff0dc45b1a3f6b70aa41950d41038d0702", - "testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md": "sha256:cac75d4475666239bbe0030b90c0fa7cc66024af5b9f8ef217e53018be64890e" + "testing/2026-07-06-pin-request-header-content-in-one-scenario.zh.md": "sha256:cac75d4475666239bbe0030b90c0fa7cc66024af5b9f8ef217e53018be64890e", + "testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml": "sha256:fc37fcdfe8744f8c8f39eda3f494eed25ee8c4d5322d1f3f671eabd2c5d7026e", + "testing/2026-07-08-shared-acp-snapshot-package.md": "sha256:285b4a3c0b1ef7a837e6713cf0192ddc8a26101f6737fa3923682a9e91350c50", + "testing/2026-07-08-shared-acp-snapshot-package.zh.md": "sha256:02da3f910c2060f70038a0d86a7ddae4a8890905600440e1373412f54fbdcea8" } } diff --git a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml b/.agents/notes/archived/process/2026-06-11-tsdown-over-dumble.i18n.yaml similarity index 65% rename from .agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml rename to .agents/notes/archived/process/2026-06-11-tsdown-over-dumble.i18n.yaml index 4ac1a926c4..0384ce1260 100644 --- a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.i18n.yaml +++ b/.agents/notes/archived/process/2026-06-11-tsdown-over-dumble.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-11-tsdown-over-dumble.md: e8cdaeb1e3331ffb04de024acff5b0e6ca3e6366 -2026-06-11-tsdown-over-dumble.zh.md: bb5feef585c1748f41d1b204313f1a4d8b357a17 +2026-06-11-tsdown-over-dumble.md: b622fd41b262147e3a42ca590ccb192dd83d0b9f +2026-06-11-tsdown-over-dumble.zh.md: 2200cb8c1776c5f50913ed7e3cfb9a6c305d31e0 diff --git a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md b/.agents/notes/archived/process/2026-06-11-tsdown-over-dumble.md similarity index 99% rename from .agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md rename to .agents/notes/archived/process/2026-06-11-tsdown-over-dumble.md index e8cdaeb1e3..b622fd41b2 100644 --- a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.md +++ b/.agents/notes/archived/process/2026-06-11-tsdown-over-dumble.md @@ -1,6 +1,7 @@ # Agent Note: tsdown for JS bundling instead of dumble Status: implemented +Archived: 2026-07-27 English | [中文](2026-06-11-tsdown-over-dumble.zh.md) diff --git a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md b/.agents/notes/archived/process/2026-06-11-tsdown-over-dumble.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md rename to .agents/notes/archived/process/2026-06-11-tsdown-over-dumble.zh.md index bb5feef585..2200cb8c17 100644 --- a/.agents/notes/implemented/process/2026-06-11-tsdown-over-dumble.zh.md +++ b/.agents/notes/archived/process/2026-06-11-tsdown-over-dumble.zh.md @@ -1,6 +1,7 @@ # Agent Note: 使用 tsdown 替代 dumble 进行 JS 打包 Status: implemented +Archived: 2026-07-27 [English](2026-06-11-tsdown-over-dumble.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml b/.agents/notes/archived/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml similarity index 62% rename from .agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml rename to .agents/notes/archived/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml index 21465703e1..d17897d305 100644 --- a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml +++ b/.agents/notes/archived/process/2026-07-04-cordis-jsdoc-completeness-gate.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-04-cordis-jsdoc-completeness-gate.md: c7c39986414437ce4d0d4c64f9e25f47485fdf5c -2026-07-04-cordis-jsdoc-completeness-gate.zh.md: f1e7ec824ebae119c1cb27ca4dd9f0d8330dde1a +2026-07-04-cordis-jsdoc-completeness-gate.md: 4b033ab52c8bfe79d1ca9db4baf3db2a533223be +2026-07-04-cordis-jsdoc-completeness-gate.zh.md: f1c1c9976deadaaa700a8a152e4f29a31122ca1a diff --git a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/.agents/notes/archived/process/2026-07-04-cordis-jsdoc-completeness-gate.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md rename to .agents/notes/archived/process/2026-07-04-cordis-jsdoc-completeness-gate.md index c7c3998641..4b033ab52c 100644 --- a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md +++ b/.agents/notes/archived/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -1,6 +1,7 @@ # Agent Note: JSDoc completeness gate for the cordis surface Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-04-cordis-jsdoc-completeness-gate.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md b/.agents/notes/archived/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md rename to .agents/notes/archived/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md index f1e7ec824e..f1c1c9976d 100644 --- a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md +++ b/.agents/notes/archived/process/2026-07-04-cordis-jsdoc-completeness-gate.zh.md @@ -1,6 +1,7 @@ # Agent Note: 针对 Cordis 对外服务接口的 JSDoc 完整性门禁 Status: implemented +Archived: 2026-07-27 [English](2026-07-04-cordis-jsdoc-completeness-gate.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml b/.agents/notes/archived/process/2026-07-04-persistence-log-catalog.i18n.yaml similarity index 64% rename from .agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml rename to .agents/notes/archived/process/2026-07-04-persistence-log-catalog.i18n.yaml index 00db07d233..122f95f28d 100644 --- a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.i18n.yaml +++ b/.agents/notes/archived/process/2026-07-04-persistence-log-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 -2026-07-04-persistence-log-catalog.md: 1529f41b485c1bc8ca029c0a9264574fa7a886a0 -2026-07-04-persistence-log-catalog.zh.md: f3f77bb66f8798d953fed16bc79cadadc35c036a +2026-07-04-persistence-log-catalog.md: f000b41c24761fa4914f296d0c2c07e4a26770d6 +2026-07-04-persistence-log-catalog.zh.md: 58680e56c5b8f5959baa90318f1747303392c9aa diff --git a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md b/.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md rename to .agents/notes/archived/process/2026-07-04-persistence-log-catalog.md index 1529f41b48..f000b41c24 100644 --- a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md +++ b/.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md @@ -1,6 +1,7 @@ # Agent Note: Generated persistence log event catalog Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-04-persistence-log-catalog.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md b/.agents/notes/archived/process/2026-07-04-persistence-log-catalog.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md rename to .agents/notes/archived/process/2026-07-04-persistence-log-catalog.zh.md index f3f77bb66f..58680e56c5 100644 --- a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.zh.md +++ b/.agents/notes/archived/process/2026-07-04-persistence-log-catalog.zh.md @@ -1,6 +1,7 @@ # Agent Note: 生成式持久化日志事件目录 Status: implemented +Archived: 2026-07-27 [English](2026-07-04-persistence-log-catalog.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml b/.agents/notes/archived/process/2026-07-06-generated-config-catalog.i18n.yaml similarity index 63% rename from .agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml rename to .agents/notes/archived/process/2026-07-06-generated-config-catalog.i18n.yaml index 17473bf069..439ef8fde1 100644 --- a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.i18n.yaml +++ b/.agents/notes/archived/process/2026-07-06-generated-config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-06-generated-config-catalog.md: f39f5138526d3278e839ee0053d5051bb8bc1c36 -2026-07-06-generated-config-catalog.zh.md: 825046914dad8e1a7d87340a310b252f03cbecb9 +2026-07-06-generated-config-catalog.md: b7c69f21c0e2152235f57e90f88f670769eb9d3b +2026-07-06-generated-config-catalog.zh.md: 59d632df0219c09d4afac9c859e2ed0a88d62550 diff --git a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.md b/.agents/notes/archived/process/2026-07-06-generated-config-catalog.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-06-generated-config-catalog.md rename to .agents/notes/archived/process/2026-07-06-generated-config-catalog.md index f39f513852..b7c69f21c0 100644 --- a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.md +++ b/.agents/notes/archived/process/2026-07-06-generated-config-catalog.md @@ -1,6 +1,7 @@ # Agent Note: Generated plugin config catalog Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-06-generated-config-catalog.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md b/.agents/notes/archived/process/2026-07-06-generated-config-catalog.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md rename to .agents/notes/archived/process/2026-07-06-generated-config-catalog.zh.md index 825046914d..59d632df02 100644 --- a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.zh.md +++ b/.agents/notes/archived/process/2026-07-06-generated-config-catalog.zh.md @@ -1,6 +1,7 @@ # Agent Note: 生成式插件配置目录 Status: implemented +Archived: 2026-07-27 [English](2026-07-06-generated-config-catalog.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml b/.agents/notes/archived/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml similarity index 62% rename from .agents/notes/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml rename to .agents/notes/archived/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml index 31ec0bdb07..b346069713 100644 --- a/.agents/notes/implemented/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml +++ b/.agents/notes/archived/process/2026-07-17-run-ci-examples-from-built-lib.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-17-run-ci-examples-from-built-lib.md: 22f69ed56bfc479281648bfb40df5acbd129ebc0 -2026-07-17-run-ci-examples-from-built-lib.zh.md: 74b985f578dd25f785e556c0cd493a7a9292fc43 +2026-07-17-run-ci-examples-from-built-lib.md: 3063bdcc8a170713151229a2a4cee22a91f1e8a9 +2026-07-17-run-ci-examples-from-built-lib.zh.md: b70367c1d25f4942043a60458b0197288c8094ba diff --git a/.agents/notes/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md b/.agents/notes/archived/process/2026-07-17-run-ci-examples-from-built-lib.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md rename to .agents/notes/archived/process/2026-07-17-run-ci-examples-from-built-lib.md index 22f69ed56b..3063bdcc8a 100644 --- a/.agents/notes/implemented/process/2026-07-17-run-ci-examples-from-built-lib.md +++ b/.agents/notes/archived/process/2026-07-17-run-ci-examples-from-built-lib.md @@ -1,6 +1,7 @@ # Agent Note: Run CI examples from built lib Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-17-run-ci-examples-from-built-lib.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md b/.agents/notes/archived/process/2026-07-17-run-ci-examples-from-built-lib.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md rename to .agents/notes/archived/process/2026-07-17-run-ci-examples-from-built-lib.zh.md index 74b985f578..b70367c1d2 100644 --- a/.agents/notes/implemented/process/2026-07-17-run-ci-examples-from-built-lib.zh.md +++ b/.agents/notes/archived/process/2026-07-17-run-ci-examples-from-built-lib.zh.md @@ -1,6 +1,7 @@ # Agent Note: 在 CI 中从构建后的 lib 运行示例 Status: implemented +Archived: 2026-07-27 [English](2026-07-17-run-ci-examples-from-built-lib.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml b/.agents/notes/archived/process/2026-07-20-generated-cordis-core-api.i18n.yaml similarity index 63% rename from .agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml rename to .agents/notes/archived/process/2026-07-20-generated-cordis-core-api.i18n.yaml index 6bae3b4d87..9d9515dd97 100644 --- a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.i18n.yaml +++ b/.agents/notes/archived/process/2026-07-20-generated-cordis-core-api.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-20-generated-cordis-core-api.md: 848dec2dba6f432c706798c40abe98e8937da651 -2026-07-20-generated-cordis-core-api.zh.md: c40a480224f4e1387b71ade9264458cd84403584 +2026-07-20-generated-cordis-core-api.md: b6a66e518744354771f25d44d662e2655fa7c758 +2026-07-20-generated-cordis-core-api.zh.md: 82dbb308386b7adc678c6e4bf8ef3f06c3b2df77 diff --git a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md b/.agents/notes/archived/process/2026-07-20-generated-cordis-core-api.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md rename to .agents/notes/archived/process/2026-07-20-generated-cordis-core-api.md index 848dec2dba..b6a66e5187 100644 --- a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.md +++ b/.agents/notes/archived/process/2026-07-20-generated-cordis-core-api.md @@ -1,6 +1,7 @@ # Agent Note: Generate the Cordis core API reference Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-20-generated-cordis-core-api.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md b/.agents/notes/archived/process/2026-07-20-generated-cordis-core-api.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md rename to .agents/notes/archived/process/2026-07-20-generated-cordis-core-api.zh.md index c40a480224..82dbb30838 100644 --- a/.agents/notes/implemented/process/2026-07-20-generated-cordis-core-api.zh.md +++ b/.agents/notes/archived/process/2026-07-20-generated-cordis-core-api.zh.md @@ -1,6 +1,7 @@ # Agent Note: 生成 Cordis 核心 API 参考文档 Status: implemented +Archived: 2026-07-27 [English](2026-07-20-generated-cordis-core-api.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.i18n.yaml b/.agents/notes/archived/process/2026-07-22-cordis-tutorial-docs.i18n.yaml similarity index 65% rename from .agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.i18n.yaml rename to .agents/notes/archived/process/2026-07-22-cordis-tutorial-docs.i18n.yaml index b2aa854cf3..25159413b2 100644 --- a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.i18n.yaml +++ b/.agents/notes/archived/process/2026-07-22-cordis-tutorial-docs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-22-cordis-tutorial-docs.md: 45abb8f524218ce0b2678606ae62c7bf2dbca00b -2026-07-22-cordis-tutorial-docs.zh.md: cd1a62e2a6e7f2e28bc32109dd67746840f8b8b7 +2026-07-22-cordis-tutorial-docs.md: a882f3f58fe4be5388bf142424e0e99e140410fc +2026-07-22-cordis-tutorial-docs.zh.md: 4caf03ee05a120584dabef91f6ebafc78594d43a diff --git a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.md b/.agents/notes/archived/process/2026-07-22-cordis-tutorial-docs.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.md rename to .agents/notes/archived/process/2026-07-22-cordis-tutorial-docs.md index 45abb8f524..a882f3f58f 100644 --- a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.md +++ b/.agents/notes/archived/process/2026-07-22-cordis-tutorial-docs.md @@ -1,6 +1,7 @@ # Agent Note: Tutorial-style Cordis docs under docs/cordis-tutorial Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-22-cordis-tutorial-docs.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.zh.md b/.agents/notes/archived/process/2026-07-22-cordis-tutorial-docs.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.zh.md rename to .agents/notes/archived/process/2026-07-22-cordis-tutorial-docs.zh.md index cd1a62e2a6..4caf03ee05 100644 --- a/.agents/notes/implemented/process/2026-07-22-cordis-tutorial-docs.zh.md +++ b/.agents/notes/archived/process/2026-07-22-cordis-tutorial-docs.zh.md @@ -1,6 +1,7 @@ # Agent Note: `docs/cordis-tutorial` 下的 Cordis 实操教程文档 Status: implemented +Archived: 2026-07-27 [English](2026-07-22-cordis-tutorial-docs.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml b/.agents/notes/archived/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml similarity index 60% rename from .agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml rename to .agents/notes/archived/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml index cbc79b0a1c..4403ee72d8 100644 --- a/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml +++ b/.agents/notes/archived/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-26-gui-pr-gif-evidence-and-assets-branch.md: 221df5dc82dfb437261f6a836dcc6c2972f30939 -2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md: 861cedb5d15c8e057c2a5f9001453fdc3a3c6757 +2026-07-26-gui-pr-gif-evidence-and-assets-branch.md: 18e56e44190e0c44fcf2cae8bf1d66f91ef9a4c7 +2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md: 945dd7a0242d9829334ddd79ab569c6723a64185 diff --git a/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md b/.agents/notes/archived/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md rename to .agents/notes/archived/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md index 221df5dc82..18e56e4419 100644 --- a/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md +++ b/.agents/notes/archived/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md @@ -1,6 +1,7 @@ # Agent Note: GUI pull request GIF evidence and assets-branch publication Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md b/.agents/notes/archived/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md rename to .agents/notes/archived/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md index 861cedb5d1..945dd7a024 100644 --- a/.agents/notes/implemented/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md +++ b/.agents/notes/archived/process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md @@ -1,6 +1,7 @@ # Agent Note: GUI PR 的 GIF 证据与 assets 分支发布 Status: implemented +Archived: 2026-07-27 [English](2026-07-26-gui-pr-gif-evidence-and-assets-branch.md) | 中文 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml b/.agents/notes/archived/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml similarity index 63% rename from .agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml rename to .agents/notes/archived/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml index 83ddffc483..c2f6f7d751 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml +++ b/.agents/notes/archived/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-02-remove-stream-chunk-mirror.md: 1d9ff86800521eb5ef226575e33a35dcaffd6f6e -2026-07-02-remove-stream-chunk-mirror.zh.md: 26dcc36038efd2857a90c15a675d843d57282ec1 +2026-07-02-remove-stream-chunk-mirror.md: 99086a179dfd1bbaac570e437a66b9be19c05ef4 +2026-07-02-remove-stream-chunk-mirror.zh.md: 7a03e476dd8d0ba725d16d220e9aef3a3fc445f6 diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/.agents/notes/archived/simplification/2026-07-02-remove-stream-chunk-mirror.md similarity index 98% rename from .agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md rename to .agents/notes/archived/simplification/2026-07-02-remove-stream-chunk-mirror.md index 1d9ff86800..99086a179d 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md +++ b/.agents/notes/archived/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -1,12 +1,13 @@ # Agent Note: Stop mirroring the token stream as an agent event Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-02-remove-stream-chunk-mirror.zh.md) ## Problem -The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/loop.ts` the two sat one line apart: +The loop records every model token delta as a durable `assistant/chunk` session event AND emitted a parallel live `agent/stream-chunk` Cordis event carrying the identical data. In `packages/core/agent-loop/src/agent.ts` the two sat one line apart: ```ts ignore-check const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) diff --git a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md b/.agents/notes/archived/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md similarity index 97% rename from .agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md rename to .agents/notes/archived/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md index 26dcc36038..7a03e476dd 100644 --- a/.agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md +++ b/.agents/notes/archived/simplification/2026-07-02-remove-stream-chunk-mirror.zh.md @@ -1,12 +1,13 @@ # Agent Note: 停止将 token 流镜像为 agent 事件 Status: implemented +Archived: 2026-07-27 [English](2026-07-02-remove-stream-chunk-mirror.md) | 中文 ## 问题 -agent loop(智能体循环)将模型的每个 token delta 同时记录为持久的 `assistant/chunk` 会话事件,并发射一个携带相同数据的并行实时 `agent/stream-chunk` Cordis 事件。在 `packages/core/agent-loop/src/loop.ts` 中,二者仅相隔一行: +agent loop(智能体循环)将模型的每个 token delta 同时记录为持久的 `assistant/chunk` 会话事件,并发射一个携带相同数据的并行实时 `agent/stream-chunk` Cordis 事件。在 `packages/core/agent-loop/src/agent.ts` 中,二者仅相隔一行: ```ts ignore-check const chunkEvent = session.append('assistant/chunk', { turn, step, chunk }) diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml b/.agents/notes/archived/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml similarity index 60% rename from .agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml rename to .agents/notes/archived/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml index fbe06d209f..8e0bf97bf0 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml +++ b/.agents/notes/archived/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-22-tui-titles-from-session-title-service.md: 04355b9c426af423dec347997f3b8ac62483eb7f -2026-07-22-tui-titles-from-session-title-service.zh.md: 5fc783c5ca08baba12a60f2aa6b5e286307aa9f0 +2026-07-22-tui-titles-from-session-title-service.md: 946ef283f91249c70f82280b754ef991665dbaa3 +2026-07-22-tui-titles-from-session-title-service.zh.md: f2fa4772c9fa404540dcf8a514cf571a284d4967 diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md b/.agents/notes/archived/simplification/2026-07-22-tui-titles-from-session-title-service.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md rename to .agents/notes/archived/simplification/2026-07-22-tui-titles-from-session-title-service.md index 04355b9c42..946ef283f9 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md +++ b/.agents/notes/archived/simplification/2026-07-22-tui-titles-from-session-title-service.md @@ -1,6 +1,7 @@ # Agent Note: TUI titles come from the session-title service Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-22-tui-titles-from-session-title-service.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md b/.agents/notes/archived/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md rename to .agents/notes/archived/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md index 5fc783c5ca..f2fa4772c9 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md +++ b/.agents/notes/archived/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md @@ -1,6 +1,7 @@ # Agent Note: TUI 标题来自 session-title 服务 Status: implemented +Archived: 2026-07-27 [English](2026-07-22-tui-titles-from-session-title-service.md) | 中文 diff --git a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml b/.agents/notes/archived/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml similarity index 59% rename from .agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml rename to .agents/notes/archived/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml index 0219885a7c..6c3719ecf4 100644 --- a/.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml +++ b/.agents/notes/archived/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-26-consolidate-gate-scripts-on-existing-deps.md: 0bf32e01ea407e2718f8ec39ca962587a37df9cc -2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: ba9f157a61ffdc415acf9b2a61857026fc2c8bf1 +2026-07-26-consolidate-gate-scripts-on-existing-deps.md: 9ad27ff8b2fe7a68511c01a1125dd01289aa4a7f +2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: b01133fad22c8d8244d787f8740d2d1c29b2c41e diff --git a/.agents/notes/archived/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md b/.agents/notes/archived/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md new file mode 100644 index 0000000000..9ad27ff8b2 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md @@ -0,0 +1,34 @@ +# Agent Note: Consolidate gate scripts on already-present deps and builtins + +Status: implemented +Archived: 2026-07-27 + +English | [中文](2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md) + +## Problem + +The `scripts/` gates mostly used the right tools (`node:fs` `globSync` in 15+ gates, mdast/micromark in the markdown gates), but a handful of stragglers hand-rolled what a sibling gate already did with an existing dependency or builtin: + +- **Duplicated fence scanners.** `scripts/md-fences.ts` (~55 lines, consumed by `doc-typecheck.ts`) and `extractEquivBlocks` in `scripts/verify-type-equiv.ts` (~39 lines) were two copies of the same regex line-scanner for fenced code blocks, while `scripts/verify-mermaid.ts` already extracted fences by visiting mdast `code` nodes — and `markdownProseLines` in `scripts/markdown.ts` itself parsed to mdast but then hand-tracked fence state with a second regex. The regex scanners only recognized backtick fences at column 0, so they silently disagreed with the mdast-based gates on tilde and indented fences. +- **Hand-rolled argv parsing.** `parseOptions` in `scripts/publint-all.ts` and its near-identical copy in `scripts/verify-built-package-invariants.mjs` (~26 lines) stepped argv indexes manually, while sibling scripts (`verify-runtime-closure.ts`, `build-exe-for-python-sdk.ts`, `packages/sdk/scripts/src/args.ts`) already used the `node:util` `parseArgs` builtin. +- **Hand-rolled directory walks.** Five sites re-derived nested `readdirSync` walks that `globSync` covers: `verify-runtime-closure.ts` (packages + vendor manifests), `dev-web.ts` `discoverPluginDirs`, `verify-package-paths.ts` `realPackageNames`, `verify-client-domain-graph.ts` `listSources`, and `publint-all.ts` `addPath` (~55–65 lines total). `scripts/package-invariants.ts` shows the one-line `globSync` template. + +No new dependency was needed anywhere; every replacement is an existing devDep or a Node builtin. + +## Decision + +- A shared mdast fence helper, `markdownFences` in `scripts/markdown.ts`, visits `code` nodes for the language, full info string, body, and 1-based opening-fence line; `doc-typecheck.ts` and `verify-type-equiv.ts` extract fences through it. `md-fences.ts` and the duplicated `extractEquivBlocks` scanner are deleted, and `markdownProseLines` derives fenced lines from the parsed `code` nodes' positions instead of a second regex. +- Both CLIs parse argv via `parseArgs`; unknown options and missing values still fail loud, with `parseArgs`'s own error text instead of the bespoke usage strings. +- The five straggler walks use `globSync`. The walks in `check-workspace-constraints.ts` and `clean.ts` stay: they need dirent-level detail to diagnose malformed trees, which glob-by-pattern cannot report. + +## Alternatives considered + +- **A new glob/walking dependency (`tinyglobby`, `fdir`).** Rejected: the builtin already won repo-wide; these were stragglers, not a gap. +- **`p-map` for `publint-all.ts`'s ~19-line ordered worker pool.** Deliberately left out: one new devDep for one small deletion is at the edge of the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md) bar, and the pool's requirements (bounded workers, deterministic order, env override) are documented in the [parallel-gates note](../process/2026-07-06-parallel-pre-push-gates.md). Fold it in only if `p-map` earns a second consumer. +- **Leaving the fence scanners.** Rejected: two drifting copies of a parser beside a third correct implementation is exactly the duplication the shared `markdown.ts` helper exists to prevent, and the column-0-backtick-only limitation was a latent inconsistency between sibling gates. + +## Consequences + +- One fence parser: every markdown gate now classifies fences through mdast, so tilde, indented, and 4-backtick container fences behave identically everywhere. The docs tree contained no fence shape the regex scanners mishandled, so gate results are unchanged on the tree that landed the swap: `pnpm run doc-sync` and each rewritten gate ran before and after with byte-identical output (`doc-typecheck` block/opt-out counts, `verify-type-equiv` match counts, `publint`, `verify-built-package-invariants`, `verify-runtime-closure`, `verify-package-paths`, `verify-client-domain-graph`, and both package-README prose gates). +- `verify-type-equiv` still rejects an unterminated type-equivalence fence: mdast silently closes an unterminated block at end-of-file (its comparisons could then pass), so the shared helper reports whether a closing delimiter exists and the gate errors on an unclosed block, preserving the removed scanner's rejection. The `doc-typecheck` scanner never had that error path. +- `parseArgs` keeps the last value of a duplicated option instead of erroring — a dev-tool edge case the tests don't pin, accepted in exchange for deleting the two bespoke parsers. (Strict mode still rejects a `--`-prefixed token where a value is expected, matching the replaced parsers.) diff --git a/.agents/notes/archived/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md b/.agents/notes/archived/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md new file mode 100644 index 0000000000..b01133fad2 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 把门禁脚本统一到已有依赖与内置模块上 + +Status: implemented +Archived: 2026-07-27 + +[English](2026-07-26-consolidate-gate-scripts-on-existing-deps.md) | 中文 + +## 问题 + +`scripts/` 下的门禁大多本已在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本曾手写同类门禁早已用既有依赖或内置模块完成的事情: + +- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)曾是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 早已通过访问 mdast `code` 节点来提取代码围栏;`scripts/markdown.ts` 自己的 `markdownProseLines` 也曾先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。 +- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)曾手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)早已在使用 `node:util` 的内置 `parseArgs`。 +- **手写的目录遍历。**五处代码曾各自重写 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。 + +所有替换都不需要引入新依赖;每一处替换用的都是既有的 devDependency 或 Node 内置模块。 + +## 决策 + +- `scripts/markdown.ts` 中的共享 mdast 围栏辅助函数 `markdownFences` 访问 `code` 节点,读取语言、完整 info string、块体以及以 1 起始的开围栏行号;`doc-typecheck.ts` 和 `verify-type-equiv.ts` 通过它提取代码围栏。`md-fences.ts` 和重复的 `extractEquivBlocks` 扫描器已删除,`markdownProseLines` 也改为从解析出的 `code` 节点位置推导围栏内的行,而不再用第二个正则。 +- 两个 CLI 都改用 `parseArgs` 解析 argv;未知选项和缺失取值仍然大声失败,只是错误文案换成了 `parseArgs` 自带的文本,而非原先手写的用法字符串。 +- 那五处掉队的目录遍历改用 `globSync`。`check-workspace-constraints.ts` 和 `clean.ts` 中的遍历保留:它们需要 dirent 级别的细节来诊断结构异常的目录树,按模式匹配的 glob 报告不了这些信息。 + +## 曾考虑的替代方案 + +- **新的 glob/目录遍历依赖(`tinyglobby`、`fdir`)。**不予采纳:内置模块已在全仓库范围内胜出;这几处只是掉队者,不是能力缺口。 +- **用 `p-map` 替换 `publint-all.ts` 中约 19 行的有序 worker 池。**刻意未纳入:为一次小删除引入一个新 devDependency,正处在[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)门槛的边缘,而且该池的需求(worker 数量有界、确定性顺序、环境变量覆盖)已记录在[并行 pre-push 门禁决策记录](../process/2026-07-06-parallel-pre-push-gates.md)中。仅当 `p-map` 赢得第二个消费方时再顺带纳入。 +- **保留这两个围栏扫描器。**不予采纳:在第三个正确实现旁边放着两份逐渐漂移的解析器拷贝,正是共享的 `markdown.ts` 辅助函数要防止的那种重复;「只认第 0 列反引号」的限制也是同类门禁之间的潜在不一致。 + +## 后果 + +- 只剩一个围栏解析器:所有 markdown 门禁现在都经由 mdast 归类代码围栏,因此波浪线围栏、缩进围栏和四反引号容器围栏在各处的行为完全一致。文档树中不存在正则扫描器处理有误的围栏形态,所以在落地这次替换的代码树上门禁结果不变:`pnpm run doc-sync` 及每个被改写的门禁在改动前后各跑一遍,输出逐字节相同(`doc-typecheck` 的块数/opt-out 计数、`verify-type-equiv` 的匹配计数、`publint`、`verify-built-package-invariants`、`verify-runtime-closure`、`verify-package-paths`、`verify-client-domain-graph`,以及两个包 README 散文门禁)。 +- `verify-type-equiv` 仍然拒绝未闭合的类型等价围栏:mdast 会在文件末尾静默闭合未闭合的代码块(其比较随后可能通过),因此共享辅助函数会报告闭合定界符是否存在,门禁在块未闭合时报错,保留了被删扫描器的这条拒绝路径。`doc-typecheck` 的扫描器本来就没有这条错误路径。 +- `parseArgs` 对重复出现的选项保留最后一个值而不报错——一个测试未固定的开发工具边缘用例,作为删除两份手写解析器的交换被接受。(严格模式下,需要取值处遇到以 `--` 开头的 token 仍会拒绝,与被替换的解析器行为一致。) diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml b/.agents/notes/archived/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml similarity index 63% rename from .agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml rename to .agents/notes/archived/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml index 4aa2ea289d..0472aed0a3 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml +++ b/.agents/notes/archived/testing/2026-07-08-shared-acp-snapshot-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-08-shared-acp-snapshot-package.md: dc86bf020b159a1c4af26bbc49725ce2b7de8180 -2026-07-08-shared-acp-snapshot-package.zh.md: 19eb070bbc5aa1a0b72c0cc874225064e92632b8 +2026-07-08-shared-acp-snapshot-package.md: 6952f79eff8a764cd296bef98e9cd7b40e59cf01 +2026-07-08-shared-acp-snapshot-package.zh.md: 35246fc4f98f616d70e31eb55499cca15f04c94e diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/.agents/notes/archived/testing/2026-07-08-shared-acp-snapshot-package.md similarity index 99% rename from .agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md rename to .agents/notes/archived/testing/2026-07-08-shared-acp-snapshot-package.md index dc86bf020b..6952f79eff 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/.agents/notes/archived/testing/2026-07-08-shared-acp-snapshot-package.md @@ -1,6 +1,7 @@ # Agent Note: Extract the ACP snapshot suite into a support package Status: implemented +Archived: 2026-07-27 English | [中文](2026-07-08-shared-acp-snapshot-package.zh.md) diff --git a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md b/.agents/notes/archived/testing/2026-07-08-shared-acp-snapshot-package.zh.md similarity index 99% rename from .agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md rename to .agents/notes/archived/testing/2026-07-08-shared-acp-snapshot-package.zh.md index 19eb070bbc..35246fc4f9 100644 --- a/.agents/notes/implemented/testing/2026-07-08-shared-acp-snapshot-package.zh.md +++ b/.agents/notes/archived/testing/2026-07-08-shared-acp-snapshot-package.zh.md @@ -1,6 +1,7 @@ # Agent Note: 将 ACP 快照套件提取为支持包 Status: implemented +Archived: 2026-07-27 [English](2026-07-08-shared-acp-snapshot-package.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml index c6ddb39d01..d5b172cb13 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.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-06-11-dev-invariants-over-deep-readonly.md: 8f0e79f15af82ce3125b1f6f767d4ea727aa6d29 -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 2f787bbd55b5a9a91bc5342351756e45cb0515d3 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +2026-06-11-dev-invariants-over-deep-readonly.md: f1a741927cf63b43b1aaf558efc148ff80d2d881 +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 67439c674f5a77112fc0619c774f7897cf49e835 diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md index 8f0e79f15a..f1a741927c 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md @@ -40,7 +40,7 @@ When the session companion attaches to an existing or seeded session, it replays ### Pervasive deep-readonly types -[The rejected immutable-public-surfaces proposal](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md) would apply a recursive readonly type across public log and message surfaces. That provides editor feedback but not a runtime guarantee: TypeScript types are erased and plugin code can cast through them. It also pushes readonly types into consumers where mutation is intentional. Runtime ownership at the `Session` boundary protects every caller without that type propagation. +A rejected companion proposal would apply a recursive `DeepReadonly` type across public log and message surfaces, flipping session read paths (`events`, `session/event` listeners, `deriveMessages()`) to deep-readonly while keeping in-flight waterfalls mutable. That provides editor feedback but not a runtime guarantee: TypeScript types are erased and plugin code can cast through them. It also pushes readonly types into consumers where mutation is intentional. Runtime ownership at the `Session` boundary protects every caller without that type propagation. ### Development-only freezing diff --git a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md index 2f787bbd55..67439c674f 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.zh.md @@ -40,7 +40,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ### 全面的 deep-readonly 类型 -[被否决的不可变公共表面提案](../../rejected/architecture/2026-06-11-immutable-public-surfaces.md)会在公共日志和消息表面上应用递归 readonly 类型。这能提供编辑器反馈,但无法提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。 +一个被否决的姊妹提案会在公共日志和消息表面上应用递归 `DeepReadonly` 类型,将会话读取路径(`events`、`session/event` 监听器、`deriveMessages()`)翻转为深只读,同时保持进行中的 waterfall 可变。这能提供编辑器反馈,但无法提供运行时保证:TypeScript 类型在运行时被擦除,插件代码可以通过类型转换绕过。它还会将 readonly 类型推入有意进行修改的消费方。在 `Session` 边界处的运行时所有权保护所有调用方,无需这种类型传播。 ### 仅在开发模式冻结 diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml index f1d2fe1a90..dc88be13a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-13-twin-llm-adapters.md: 5c3308b281ce71407002e95dd6e794da2a421fa8 -2026-06-13-twin-llm-adapters.zh.md: 93b084973bccaeb802508e4e939a259c281f2608 +2026-06-13-twin-llm-adapters.md: b922891d4438553fd96a7f4f4226f378e66e8ad2 +2026-06-13-twin-llm-adapters.zh.md: d98b57a0a2e7c92453046022e8cb50aa52994c0f diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md index 5c3308b281..b922891d44 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md @@ -12,10 +12,10 @@ English | [中文](2026-06-13-twin-llm-adapters.zh.md) Ship **two** adapters against the one contract from the start, deliberately built on different internals: -- `dsh-llm-deepseek` — hand-rolled `fetch` + SSE parsing against the DeepSeek API. +- `dsh-llm-deepseek` — direct `fetch` + in-repo translation against the DeepSeek API; SSE framing is delegated to `eventsource-parser` ([the SSE-parser swap](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md)). The twin identity is owning the fetch/translate internals rather than delegating to a full provider SDK, not hand-rolling transport plumbing. - `dsh-llm-pi-ai` — the same endpoint through the `@earendil-works/pi-ai` library (its own event vocabulary). -The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single hand-rolled adapter would have hidden. +The rule they enforce: **anything the StreamChunk vocabulary cannot express for BOTH implementations is a core-vocabulary bug**, caught immediately rather than at the next provider. The pair pinned down conventions now documented on `StreamChunk` in `dsh-llm/src/types.ts`: usage emitted before finish, nothing after finish, tool-call `arguments` as raw JSON strings end-to-end, and the two sanctioned error paths (throw from `stream()` *or* end with `finish {kind:'error'|'aborted'}`) that a consumer must handle on both sides — a divergence the library-backed adapter surfaced that a single direct-fetch adapter would have hidden. ## Alternatives considered @@ -24,4 +24,4 @@ The rule they enforce: **anything the StreamChunk vocabulary cannot express for ## Consequences -The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the hand-rolled adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding Agent Note. +The twin doubles adapter and key-gated e2e maintenance—both cover V4 Flash and Pro across representative reasoning modes—in exchange for continuous seam-neutrality validation and a second implementation example. Both use `apiKey`, `baseURL`, and `models`; the direct-fetch adapter exposes `thinking`/`reasoningEffort`, while pi-ai exposes one `reasoning` level. A future conformance suite could justify retiring one adapter through a superseding Agent Note. diff --git a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md index 93b084973b..d98b57a0a2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.zh.md @@ -12,10 +12,10 @@ Status: implemented 从一开始就针对同一份契约交付**两个**适配器,刻意基于不同的内部实现构建: -- `dsh-llm-deepseek`:手写 `fetch` + SSE(Server-Sent Events)解析,直接对接 DeepSeek API。 +- `dsh-llm-deepseek`:直接 `fetch` + 仓库内翻译逻辑对接 DeepSeek API;SSE(Server-Sent Events)分帧委托给 `eventsource-parser`([SSE 解析器替换](../simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md))。孪生身份在于自行持有 fetch/translate 内部实现而非委托给完整的提供方 SDK,不在于手写传输层管道。 - `dsh-llm-pi-ai`:通过 `@earendil-works/pi-ai` 库访问同一端点(该库有自己的事件词汇)。 -二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一手写适配器会将其隐藏。 +二者共同执行的规则是:**凡 StreamChunk 词汇无法为两个实现同时表达的内容,都是核心词汇的缺陷**——立即暴露,而非等到下一个提供方接入时才发现。这对孪生体确定了现已记录在 `dsh-llm/src/types.ts` 中 `StreamChunk` 上的约定:usage 在 finish 之前发出、finish 之后不再有任何事件、工具调用的 `arguments` 全程以原始 JSON 字符串传递,以及消费方必须在两侧都处理的两条合法错误路径(`stream()` 抛异常,*或者*以 `finish {kind:'error'|'aborted'}` 结束)。后一项分歧正是由基于库的适配器暴露出来的,单一直接 fetch 适配器会将其隐藏。 ## 曾考虑的替代方案 @@ -24,4 +24,4 @@ Status: implemented ## 后果 -孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;手写适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。 +孪生体使适配器和需要密钥的 e2e 维护量翻倍——两者都覆盖 V4 Flash 和 Pro 在各代表性推理(reasoning)模式下的行为——换来的是持续的 seam 中立性验证和第二份实现示例。两个适配器均使用 `apiKey`、`baseURL` 和 `models`;直接 fetch 适配器暴露 `thinking`/`reasoningEffort`,pi-ai 适配器暴露一个 `reasoning` 级别。未来如果有一致性测试套件,可以通过后续 Agent Note 论证退役其中一个适配器。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index 1c6f4e06ac..0d6a23bebe 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.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-06-21-bounded-llm-request-recovery.md: 22a56dc6d69340ca1b5f7b77edb4731066c9b2f5 -2026-06-21-bounded-llm-request-recovery.zh.md: 09ebce376a206591ac766067cc41497b74ed1545 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +2026-06-21-bounded-llm-request-recovery.md: 83d47e3a7d91bbcd2ceaf7b11cf13316142eb3ed +2026-06-21-bounded-llm-request-recovery.zh.md: 00dcbad3d1023ad33a22297bfe938b94bce839d4 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 22a56dc6d6..83d47e3a7d 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -4,11 +4,13 @@ Status: implemented English | [中文](2026-06-21-bounded-llm-request-recovery.zh.md) +The [per-provider request retry policy](../feature/2026-07-24-provider-retry-policies.md) extends this foundation with exact-provider configuration and an explicit unbounded mode. This note continues to own structured failure facts, the closed-step recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. + ## Problem -`dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. The default decision is `fail`; `dsh-compact-basic` is the only shipped recovery listener, and it retries a canonical context-window overflow only after compaction proves that the durable surface shrank. +`dsh-llm` can report provider failures either by throwing during adapter dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary tags thrown failures so `dsh-agent-loop` can distinguish them from middleware and result-processing defects, and the loop normalizes both delivery forms into `agent/request-error` after closing the failed step. An unhandled failure is terminal; a handling listener repairs policy-owned state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns this return contract. -That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry opens a new numbered step from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate. +That boundary is already safe for another request attempt. Raw `assistant/chunk` events carry the failed `turn` and `step`, message derivation ignores them unless a successful `assistant/message` cites them, tool calls are dispatched only after a successful terminal finish and assembly, and a retry opens a new numbered turn from the durable log. The harness therefore does not need a second response lifecycle or tentative-output protocol to keep two attempts separate. The prior boundary left three narrower gaps. @@ -16,7 +18,7 @@ The prior boundary left three narrower gaps. - Retry ownership differs by adapter. The hand-written DeepSeek adapter makes one attempt, while pi-ai profiles can enable opaque library retries. Combining hidden transport retries with an `agent/request-error` listener would multiply attempts and omit intermediate failures from the session log. - A recovered failure has no durable status fact. The failed step and chunks remain reconstructable, but an observer cannot tell whether the agent is deliberately backing off, for how long, or why. A long silent wait looks like a stalled loop. -The goal is bounded recovery from transient failures of the same explicit provider/model request. Provider or model failover, response splicing, and semantic output repair are different problems and have no current consumer. +The default policy provides bounded recovery from transient failures of the same explicit provider/model request. Provider or model failover, response splicing, and semantic output repair are different problems and have no current consumer. ## Decision @@ -50,31 +52,19 @@ The shared transient-code set is intentionally small: adapter mappings for `RATE `@deepseek-ai/dsh-llm-retry` is a function plugin that listens to `agent/request-error`. It introduces no service or new loop branch; the agent-loop package changes only the data carried through its existing failed-step recovery control flow. -The `agent/request-error` seam carries the current `LlmFailure` and an immutable list of prior failures that led to another request attempt in this consecutive recovery sequence. `dsh-llm-retry` counts only prior failures whose codes are in its configured transient set, while `dsh-compact-basic` counts only prior context-overflow failures. A successful model request clears the history. Alternating transient and context-overflow failures therefore consume their owning policy budgets independently; the maximum request count is one plus the sum of the finite budgets of the loaded recovery policies. +The `agent/request-error` seam carries the current `LlmFailure`, an immutable list of prior failures that authorized retry turns in the consecutive recovery sequence, and the serving registration's immutable retry policy. The loop transports but does not interpret that policy, owns the consecutive failure history, and clears it after a successful model request. Normal `dsh-llm-retry` policy counts durable retry records scheduled by the same exact-provider policy, while `dsh-compact-basic` keeps its own context-overflow budget. Alternating transient and context-overflow failures therefore consume their owning finite budgets independently; the maximum request count is one plus the sum of the loaded finite budgets. -The plugin resolves and validates this deployment configuration at load: - -```ts ignore-check -interface Config { - maxTransientRetries?: number - initialDelayMs?: number - maxDelayMs?: number - jitterRatio?: number - retryableCodes?: string[] -} -``` - -The defaults are two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five transient codes above (`RATE_LIMIT`, `SERVER`, `TIMEOUT`, `TRANSPORT`, and `EMPTY_RESPONSE`). The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). Two retries mean at most three provider requests when no other recovery policy applies. `maxTransientRetries` is a non-negative integer, delays are positive finite numbers with `initialDelayMs <= maxDelayMs`, `jitterRatio` is in `[0, 1]`, and codes are non-empty and unique. These are Cordis config fields rather than hidden constants so deployments can choose different cost and latency budgets. +The [provider-policy decision](../feature/2026-07-24-provider-retry-policies.md) owns the current configuration shape. Provider adapters register their nested `retryPolicy`; omission uses normal defaults: two transient retries, a 500 millisecond initial delay, a 10 second delay cap, 10 percent jitter, and the five transient codes above. The count and delay bounds match the conservative edge of the inspected implementations: [OpenCode uses two request retries with 500 ms/10 s bounds](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39), [Pi separates three agent-level retries from provider retries and defaults provider retries to zero](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147), and [Codex uses finite request/stream budgets plus a five-minute idle timeout](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33). Ten percent follows [Codex's bounded jitter](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47). For an eligible failure with budget remaining, the one-based transient retry count uses bounded exponential backoff. A valid `providerRetryAfterMs` replaces exponential backoff only when it does not exceed `maxDelayMs`; a longer provider delay causes delegation instead of an earlier retry that violates the provider instruction. Local backoff multiplies by an injected random factor in `[1 - jitterRatio, 1 + jitterRatio]` and clamps the final value to `maxDelayMs`; provider delay is not jittered. -The plugin owns a lifetime `AbortController` and tracks every active backoff callback. Each wait fuses the waterfall's turn signal with that lifetime signal. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; a captured callback whose lifetime signal aborts returns `fail` and can neither retry nor enter the rest of its captured waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. +The plugin owns a lifetime `AbortController` and tracks every active recovery callback, including delegated waterfall work and backoff. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; abort wins over a late delegated retry decision, and a captured callback can neither retry nor enter the rest of its waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener. -Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, one-based transient retry number, configured maximum, scheduled delay, and `LlmFailure`. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. +Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, complete resolved-policy key, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The key sorts the code set and separates retry histories when a provider route is replaced by a behaviorally different same-mode policy. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection. -The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. It returns `{ action: 'retry' }` only after the delay completes under both signals; turn cancellation and plugin disposal return `fail`, after which the loop's cancellation/disposal checks remain authoritative. +The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. For an owned failure it records and awaits the delay, then returns `{ kind: 'retry' }` without delegating. Turn cancellation and plugin disposal end the wait without returning a retry; the loop's cancellation/disposal checks remain authoritative. -The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same bounded policy. Library consumers retain explicit plugin composition: omitting the plugin leaves `agent/request-error` at its current fail default. +The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same provider-routed policy. Library consumers retain explicit plugin composition: omitting the plugin leaves request failures terminal. ### Make one layer own visible attempts @@ -92,7 +82,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada ### Keep attempts separate in the existing log -A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry opens the next numbered step, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records terminal failure; message derivation continues to ignore the failed chunks. +A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure; message derivation continues to ignore the failed chunks. If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added. @@ -101,7 +91,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - Automatic provider or model failover. Requests already select one explicit provider and model, and the provider registry deliberately has one adapter owner per provider. - Retrying or continuing after a successful terminal finish, or splicing chunks from two attempts into one assistant message. - Repairing malformed tool arguments, refusals, content filters, or other semantic model output. -- Unbounded retries, unattended retry-until-cancelled behavior, circuit breakers, shared provider health, or cross-agent retry budgets. +- Circuit breakers, shared provider health, or cross-agent retry budgets. - Changing `llm/stream` into a response lifecycle or adding convenience generation APIs without a production consumer. ## Alternatives considered @@ -110,7 +100,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - **Add response start, interrupted, discarded, failed, and committed events to `dsh-llm`** — rejected because the agent log already separates raw chunks, successful messages, and numbered attempts. A second state machine would duplicate ownership without enabling the bounded same-route retry. - **Add logical routes, capability matrices, and failover selection** — rejected because current requests already name provider and model explicitly, one adapter owns each provider, and no current consumer requires automatic fallback or can prove semantic compatibility. - **Put `retryable` or `failover` on `LlmFailure`** — rejected because adapters report facts while deployment policy decides action. The same 429 may be retried in an interactive bundle and rejected in a cost-capped batch. -- **Retry forever while the caller remains active** — rejected because it gives one request unbounded cost and latency. Visible status makes bounded waiting understandable; it does not make an unlimited budget safe. +- **Retry forever while the caller remains active** — the [per-provider policy](../feature/2026-07-24-provider-retry-policies.md) supersedes this rejection for explicit `always` entries while retaining bounded normal mode as the default. - **Log retry status only through the process logger** — rejected because process logs do not reconstruct session behavior and cannot drive replayed UI state. - **Keep only flat codes** — rejected because retry delay and provider request id are structured provider facts, and HTTP status is necessary for diagnosis when different wire failures share one stable code. @@ -120,11 +110,11 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - An adapter-thrown `Error` reaches `agent/request-error` as the exact same object while its sidecar `LlmFailure` reaches the adjacent argument; tests retain the existing identity assertion for extensible and frozen third-party errors. - DeepSeek and pi-ai adapter tests cover representative 400, 401/403, 429, 5xx, connection, malformed/truncated stream, timeout, abort, retry-after seconds/date, request-id, and unknown-SDK-error paths without recovery policy parsing message text. - Pi-ai pins the SDK option to zero retries and performs one observed wire attempt for a retryable provider response; separate tests make removing either boundary fail. -- `agent/request-error` carries current failure facts plus immutable prior-retried failure facts; a success clears that history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. -- `dsh-llm-retry` validates every config field at Loader startup, delegates all ineligible paths with `next()`, and makes at most `maxTransientRetries + 1` provider requests when no other policy applies. +- `agent/request-error` carries current failure facts, immutable prior-retried failure facts, and the serving registration's immutable retry policy; a success clears the history, and alternating transient/context-overflow integration tests prove the two policies consume only their own finite budgets. +- Each provider adapter validates its nested retry policy at Loader startup, and `ctx.llm` captures it with the route; normal mode delegates ineligible paths and makes at most `maxRetries + 1` provider requests when no other policy applies. - HMR-during-backoff tests prove disposal unregisters the listener, aborts and awaits its captured callbacks, emits no retry decision after disposal, and leaves no timer or promise alive. - Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff. -- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new step, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. +- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery. - The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance. - The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. - Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it. @@ -132,12 +122,12 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` ## Consequences -- Every transient recovery attempt is visible as a closed step plus `llm/retry`, and the bounded policy prevents hidden SDK retries from multiplying cost. A retry can still duplicate provider billing even when no chunk arrived; the finite attempt budget limits but cannot remove that risk. +- Every retry attempt is visible as a closed failed turn plus `llm/retry`, and adapter-level single-attempt behavior prevents hidden SDK retries from multiplying policy decisions. A retry can still duplicate provider billing even when no chunk arrived; normal mode limits that risk, while explicit always mode accepts it until cancellation or success. - Provider SDKs may hide status or retry headers. Those adapters retain the stable facts they expose and otherwise use a coarse code rather than letting recovery policy parse fragile text. - Durable retry events expand the session protocol and UI state machine. Shipping the event and its consumer together prevents an unused telemetry vocabulary, but later schema changes still require persistence and replay work. - Clearing a failed step's live chunks can visibly retract output. That is preferable to presenting discarded text or partial tool JSON as committed history, and snapshots pin the transition. - Adapter-local idle enforcement stops stalled transports without counting consumer think time. Contract tests at each transport boundary guard against SDK drift. -- Multiple recovery plugins add their finite budgets. Their classifiers remain disjoint here; an overlapping classifier would be registration-order policy and must be documented and tested by the plugins that introduce it. +- Multiple normal recovery plugins add their finite budgets. Always mode delegates first and then supplies an unbounded fallback; overlapping classifiers remain registration-order policy and must be documented and tested by the plugins that introduce them. ## Related diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index 09ebce376a..00dcbad3d1 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -4,11 +4,13 @@ Status: implemented [English](2026-06-21-bounded-llm-request-recovery.md) | 中文 +[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。 + ## 问题 -`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。默认决策为 `fail`;`dsh-compact-basic` 是唯一已交付的恢复监听器,它仅在压缩(compaction)证明持久表层已缩减后,才会对规范化的上下文窗口溢出进行重试。 +`dsh-llm` 可能在适配器分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束,以这两种形式报告提供方失败。最终适配器边界会标记抛出的失败,使 `dsh-agent-loop` 能将其与中间件和结果处理缺陷区分开。循环关闭失败步骤后,会把两种交付形式统一规范化为 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall 委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回契约。 -该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号步骤。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 +该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号轮次。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 此前的边界还留有三个较窄的缺口。 @@ -16,7 +18,7 @@ Status: implemented - 重试的归属因适配器而异。手写 DeepSeek 适配器只尝试一次,pi-ai profile 则可以启用库内部的不透明重试。如果把隐藏的传输重试与 `agent/request-error` 监听器结合,尝试次数会成倍增加,中间失败也不会记入会话日志。 - 恢复后的失败没有持久状态事实。失败的步骤和分片仍可重建,但观察者无法得知 agent(智能体)是否在有意退避、将等待多久,以及等待原因。长时间的静默等待看起来与循环停滞无异。 -本决策的目标是从同一个显式提供方/模型请求的暂时性失败中进行有界恢复。提供方或模型故障转移、响应拼接和语义输出修复都属于其他问题,目前没有消费方。 +默认策略的目标是从同一个显式提供方/模型请求的暂时性失败中进行有界恢复。提供方或模型故障转移、响应拼接和语义输出修复都属于其他问题,目前没有消费方。 ## 决策 @@ -50,31 +52,19 @@ agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误 `@deepseek-ai/dsh-llm-retry` 是监听 `agent/request-error` 的函数插件。它不引入服务或新的循环分支;agent-loop 包仅会更改通过现有失败步骤恢复控制流携带的数据。 -`agent/request-error` seam 携带当前 `LlmFailure`,以及在这段连续恢复序列中导致再次请求的不可变先前失败列表。`dsh-llm-retry` 只计数 code 位于已配置暂时性集合中的先前失败,`dsh-compact-basic` 则只计数先前的上下文溢出失败。模型请求成功后会清空历史。因此,暂时性失败与上下文溢出交替出现时,两种策略会独立消耗各自预算;最大请求数等于 1 加上已加载恢复策略的有限预算总和。 +`agent/request-error` seam 携带当前 `LlmFailure`、在连续恢复序列中授权重试轮次的不可变先前失败列表,以及实际服务注册所对应的不可变重试策略。循环只传递而不解释该策略;它拥有连续失败历史,并在模型请求成功后清除。`dsh-llm-retry` 的 normal 策略统计由同一项确切提供方策略安排的持久重试记录,`dsh-compact-basic` 则维护自己的上下文溢出预算。因此,暂时性失败与上下文溢出交替出现时,会各自独立消耗其有限预算;最大请求数等于 1 加上所有已加载有限预算之和。 -该插件在加载时解析并验证以下部署配置: - -```ts ignore-check -interface Config { - maxTransientRetries?: number - initialDelayMs?: number - maxDelayMs?: number - jitterRatio?: number - retryableCodes?: string[] -} -``` - -默认值为两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code(`RATE_LIMIT`、`SERVER`、`TIMEOUT`、`TRANSPORT` 和 `EMPTY_RESPONSE`)。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。在没有其他恢复策略时,两次重试表示最多发起三次提供方请求。`maxTransientRetries` 是非负整数,延迟是正的有限数且满足 `initialDelayMs <= maxDelayMs`,`jitterRatio` 位于 `[0, 1]`,code 非空且不重复。这些都是 Cordis 配置字段,而不是隐藏常量,使部署能够选择不同的成本与延迟预算。 +当前配置形状由[提供方策略决策](../feature/2026-07-24-provider-retry-policies.md)规定。提供方适配器会注册嵌套的 `retryPolicy`;省略时使用 normal 默认值:两次暂时性重试、500 毫秒初始延迟、10 秒延迟上限、10% 抖动,以及上述五个暂时性 code。计数与延迟边界参考了所调查实现中较保守的一端:[OpenCode 使用两次请求重试,延迟边界为 500 毫秒/10 秒](https://github.com/anomalyco/opencode/blob/9976269ab1accfc9f9dc98a4a688c516934de422/%70ackages/llm/src/route/executor.ts#L36-L39);[Pi 将三次 agent 级重试与提供方重试分开,且提供方重试默认为零](https://github.com/earendil-works/pi/blob/3da591ab74ab9ab407e72ed882600b2c851fae21/%70ackages/coding-agent/docs/settings.md#L139-L147);[Codex 使用有限请求/流预算以及五分钟空闲超时](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/model-provider-info/src/lib.rs#L25-L33)。10% 抖动参考 [Codex 的有界抖动](https://github.com/openai/codex/blob/0fb559f0f6e231a88ac02ea002d3ecd248e2b515/codex-rs/codex-client/src/retry.rs#L40-L47)。 对于预算未耗尽的合格失败,从 1 开始的暂时性重试计数使用有界指数退避。有效的 `providerRetryAfterMs` 只有在不超过 `maxDelayMs` 时才会取代指数退避;提供方延迟更长时,系统会委托给下一监听器,而不会违反提供方指令提前重试。本地退避乘以 `[1 - jitterRatio, 1 + jitterRatio]` 内的注入随机因子,并将最终值限制到 `maxDelayMs`;提供方延迟不加抖动。 -插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的退避回调。每次等待都会融合 waterfall(瀑布式事件)的轮次信号与该生命期信号。effect 清理会先注销监听器,再中止并等待活跃回调;被捕获回调的生命期信号中止时,回调会返回 `fail`,既不能重试,也不能在插件释放后进入其捕获 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。 +插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的恢复回调,包括委托的 waterfall(瀑布式事件)工作与退避。effect 清理会先注销监听器,再中止并等待活跃回调;中止会胜过较晚到达的委托重试决策,被捕获的回调在插件释放后既不能重试,也不能进入其 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。 -休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、从 1 开始的暂时性重试编号、已配置上限、计划延迟和 `LlmFailure`。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。 +休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、提供方、策略 mode、完整的解析策略 key、提供方策略重试编号、该 mode 存在时的有限上限、计划延迟和 `LlmFailure`。该 key 会对 code 集排序,并在提供方路由被行为不同但 mode 相同的策略替换时分隔重试历史。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。 -对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。只有在两个信号下完成延迟后,它才会返回 `{ action: 'retry' }`;轮次取消和插件释放会返回 `fail`,此后仍以循环的取消/释放检查为准。 +对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。对自身处理的失败,它会记录并等待延迟,然后在不委托的情况下返回 `{ kind: 'retry' }`。轮次取消和插件释放会结束等待且不返回重试动作,此后仍以循环的取消/释放检查为准。 -agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一有界策略。库消费方仍需显式组合插件:省略该插件时,`agent/request-error` 保持现有的 fail 默认值。 +agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一套按提供方路由的策略。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。 ### 由单一层负责可见的尝试 @@ -92,7 +82,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在现有日志中分隔尝试 -一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会开启下一个编号步骤,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录终止失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。 +一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。 如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。 @@ -101,7 +91,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - 自动提供方或模型故障转移。请求已显式选择一个提供方和模型,提供方注册表也有意规定每个提供方只由一个适配器负责。 - 在成功的终止性 finish 后重试或继续,或将两次尝试的分片拼接成一条 assistant 消息。 - 修复格式错误的工具参数、拒答、内容过滤或其他语义模型输出。 -- 无界重试、无人值守地持续重试直至取消、熔断器、共享提供方健康状态或跨 agent 重试预算。 +- 熔断器、共享提供方健康状态或跨 agent 重试预算。 - 在没有生产消费方的情况下,把 `llm/stream` 改造成响应生命周期或增加便利的生成 API。 ## 考虑过的替代方案 @@ -110,7 +100,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - **向 `dsh-llm` 增加响应开始、中断、丢弃、失败和提交事件**:拒绝采用,因为 agent 日志已经分隔原始分片、成功消息和编号尝试。第二套状态机会重复归属关系,又不能支持有界的同路由重试。 - **增加逻辑路由、能力矩阵和故障转移选择**:拒绝采用,因为当前请求已经显式指定提供方和模型,每个提供方由一个适配器负责,而且没有当前消费方要求自动回退或能够证明语义兼容性。 - **把 `retryable` 或 `failover` 放在 `LlmFailure` 上**:拒绝采用,因为适配器报告事实,部署策略决定动作。同一个 429 可以在交互式组合包中重试,也可以在成本受限的批处理中被拒绝。 -- **只要调用方仍处于活跃状态就无限重试**:拒绝采用,因为这会让一次请求产生无界成本和延迟。可见状态能使有界等待易于理解,却不能让无限预算变得安全。 +- **只要调用方仍处于活跃状态就无限重试**:[按提供方配置的策略](../feature/2026-07-24-provider-retry-policies.md)对显式 `always` 配置项推翻了这项拒绝,同时保留有界的 normal mode 作为默认值。 - **只通过进程 logger 记录重试状态**:拒绝采用,因为进程日志无法重建会话行为,也不能驱动回放后的 UI 状态。 - **只保留扁平 code**:拒绝采用,因为重试延迟和提供方请求 id 是结构化的提供方事实,而当不同协议失败共用一个稳定 code 时,诊断还需要 HTTP 状态。 @@ -120,11 +110,11 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - 适配器抛出的 `Error` 会以完全相同的对象抵达 `agent/request-error`,其伴随的 `LlmFailure` 则抵达相邻参数;测试保留针对可扩展及冻结第三方错误的现有对象标识断言。 - DeepSeek 和 pi-ai 适配器测试覆盖具有代表性的 400、401/403、429、5xx、连接、格式错误/截断流、超时、中止、Retry-After 秒数/日期、请求 id 和未知 SDK 错误路径,恢复策略无需解析消息文本。 - Pi 将 SDK 选项固定为零次重试,并针对可重试的提供方响应执行一次可观测的实际网络请求;独立测试确保移除任一边界都会失败。 -- `agent/request-error` 携带当前失败事实以及不可变的先前已重试失败事实;成功会清除该历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 -- `dsh-llm-retry` 在 Loader 启动时验证每个配置字段,使用 `next()` 委托所有不合格路径,而且在没有其他策略时最多发起 `maxTransientRetries + 1` 次提供方请求。 +- `agent/request-error` 携带当前失败事实、不可变的先前已重试失败事实,以及实际服务注册所对应的不可变重试策略;成功会清除历史,暂时性失败/上下文溢出交替发生的集成测试证明两种策略只消耗各自的有限预算。 +- 每个提供方适配器都在 Loader 启动时验证其嵌套重试策略,`ctx.llm` 则将该策略与路由一同捕获;normal mode 会委托不合格路径,而且在没有其他策略时最多发起 `maxRetries + 1` 次提供方请求。 - 退避期间执行 HMR 的测试证明:释放过程会注销监听器、中止并等待其捕获的回调,释放后不发出重试决策,也不留下存活的定时器或 promise。 - 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。 -- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新步骤中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 +- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新轮次中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。 - 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。 - 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 撤回和计划重试渲染。无密钥快照覆盖调度、取消、成功和耗尽;ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 - 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。 @@ -132,12 +122,12 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ## 后果 -- 每次暂时性恢复尝试都以一个已关闭步骤加 `llm/retry` 的形式可见,有界策略还会防止隐藏的 SDK 重试成倍增加成本。即使没有分片到达,重试仍可能造成提供方重复计费;有限的尝试预算只能限制而无法消除此风险。 +- 每次重试尝试都以一个已关闭失败轮次加 `llm/retry` 的形式可见,适配器级的单次尝试行为会防止隐藏的 SDK 重试成倍增加策略决策。即使没有分片到达,重试仍可能造成提供方重复计费;normal mode 会限制此风险,而显式 always mode 会接受它,直至取消或成功。 - 提供方 SDK 可能隐藏状态或重试标头。适配器会保留 SDK 公开的稳定事实,否则使用粗粒度 code,而不会让恢复策略解析脆弱的文本。 - 持久重试事件扩展了会话协议和 UI 状态机。事件与其消费方一同交付,可避免产生无人使用的遥测词汇;但以后更改 schema 仍需要同步完成持久化和回放工作。 - 清除失败步骤的实时分片可能会明显撤回输出。与把丢弃的文本或不完整工具 JSON 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。 - 适配器局部的空闲强制机制可以终止停滞的传输,而不会计入消费方思考时间。每个传输边界的契约测试会防止 SDK 漂移。 -- 多个恢复插件会叠加各自的有限预算。此处它们的分类器互不重叠;重叠的分类器会形成依赖注册顺序的策略,必须由引入它们的插件记录并测试。 +- 多个 normal 恢复插件会叠加各自的有限预算。always mode 会先委托,再提供无界回退;重叠的分类器仍会形成依赖注册顺序的策略,必须由引入它们的插件记录并测试。 ## 相关资料 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml index 51d0eb9a78..690aa1f9b4 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 284cd45a66294dbc9e8207a1e00e9642d32d4e58 -2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9486f8c35c5060150b072fb673acca5d4167ec1a +2026-06-30-bash-stdin-env-trusted-plugin-surface.md: 556d5dd86dfcc92c4628e68c19390f0033560d25 +2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md: 9d67797f86903e70e7bdcd6f80f19d17c41ac18e diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md index 284cd45a66..556d5dd86d 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md @@ -18,7 +18,7 @@ Three deliberate choices: 1. **The model-facing tool omits `stdin` and `env`.** Shell syntax already covers those needs, so duplicate parameters would add surface without authority separation. The tool builds requests only from declared model arguments, signal, and owner; trusted in-process callers may set the seam fields directly. Harness-owned variables use the separate `dshEnv` channel from the [managed environment decision](../feature/2026-07-10-agent-session-identity-and-log-location.md), so ordinary `env` cannot replace them. -2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision reserves `DSH_*`: ambient entries are removed, ordinary `env` cannot set them, and trusted `dshEnv` merges last. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. +2. **`env` merges AFTER the credential scrub, so an explicit caller entry wins even on a credential-shaped name.** The later managed-namespace decision manages `DSH_*`: ambient entries are removed, and trusted `dshEnv` merges last, so an ordinary `env` entry can never displace a managed value. The complete order is `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → ordinary `env` → `dshEnv`. 3. **`stdin`/`env` are required-absent-OK (plain optional) on the resolved spec, NOT required-but-nullable like `owner`.** `owner` is required-but-nullable because a *silently* missing owner yields an unowned, cross-session-readable task — a security footgun that a visible `undefined` guards against. `stdin`/`env` have no such hazard: a missing one means "no stdin / no extra env", which is the safe, ordinary case (every model-driven call). So they stay plain optionals, matching `signal`. diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md index 9486f8c35c..9d67797f86 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.zh.md @@ -18,7 +18,7 @@ Status: implemented 1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置 seam 字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 -2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策保留 `DSH_*`:环境条目会被移除,普通 `env` 无法设置它们,受信的 `dshEnv` 最后合并。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 +2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策托管 `DSH_*`:环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 3. **`stdin`/`env` 在已解析 spec 上是 required-absent-OK(普通 optional),而非像 `owner` 那样 required-but-nullable。** `owner` 之所以是 required-but-nullable,是因为*静默*缺失的 owner 会产生一个无主、跨会话可读的任务——一个安全隐患,显式的 `undefined` 可以防范。`stdin`/`env` 没有这种风险:缺失意味着「无 stdin / 无额外 env」,这是安全的常规情况(所有模型驱动的调用都如此)。因此它们保持普通 optional,与 `signal` 一致。 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index d2ac37af62..6d8498e21c 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.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-02-tool-render-intent-union.md: 84423e9000526848a111591c1bb2ab92067bbe50 -2026-07-02-tool-render-intent-union.zh.md: 43873c622fc8483b4a7033d17b4b4fab1342a56b +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md +2026-07-02-tool-render-intent-union.md: d82141f519bff66df000f1316093aacd38b8e42b +2026-07-02-tool-render-intent-union.zh.md: 71fe81ba8f87cb707512cf7880126c006321c799 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md index 84423e9000..d82141f519 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -14,7 +14,7 @@ A tool declares how its calls render in a UI (an editor's tool-call card) throug - Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense. - There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff. -The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." The rejected Agent Note [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers. +The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." An earlier rejected collapse-tool-owned-presentation proposal deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is met by multiple producer families plus the TUI and host/client-runtime (Web) consumers. ## Decision @@ -62,7 +62,7 @@ The terminal intent is display-only. The harness still executes the command thro ## Alternatives considered -- **Delete tool-owned presentation entirely** — [the rejected collapse proposal](../../rejected/simplification/2026-06-20-generic-tool-rendering.md); its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met. +- **Delete tool-owned presentation entirely** — the rejected collapse proposal this note supersedes; its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met. - **Let a UI execute terminal intents** — rejected because it would bypass the harness's bash policy and ownership contracts and fork command execution across backends. A terminal card describes harness-owned execution; it never authorizes client-side execution. - **A merge-extensible union** (the `ContentBlockMap` pattern) — rejected: a new render intent needs new bridge code to render it anyway, so a plugin-added variant the bridge silently drops would be worse than the compile error the closed union raises at the bridge's `assertNever` switch. - **Keeping the optional-field bag** — the status quo the Problem dissects: invalid states representable, undocumented field interactions, and no way to ask for a diff card at all. @@ -77,6 +77,6 @@ A new render intent is a compile-breaking change at the bridge switch — delibe ## Related -- Supersedes the deferral in [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) (rejected — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union. -- Extended by [Result-time applied-hunk diffs](2026-07-02-result-time-applied-hunk-diffs.md), which adds a persisted `meta` channel so write/edit emit a result-time `DiffResultView` — the applied change (a contextual hunk with context lines / one per `replace_all` site, or a whole-file diff for a create) — on top of this union's call-time diff card. +- Supersedes the deferral in the earlier rejected collapse-tool-owned-presentation proposal (rejected — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union. +- Extended by [Result-time applied-hunk diffs](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md) (archived), which added a persisted `meta` channel — the value/presentation split and the persisted `presentationMeta` channel are now owned by [the canonical tool output contract](2026-07-20-canonical-tool-output-contract.md) so write/edit emit a result-time `DiffResultView` — the applied change (a contextual hunk with context lines / one per `replace_all` site, or a whole-file diff for a create) — on top of this union's call-time diff card. - Folds `ToolTerminal` into the tagged `terminal` views used by current UI transports. diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index 43873c622f..71fe81ba8f 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -14,7 +14,7 @@ Status: implemented - 哪些组合是*合法的*没有文档说明:一个设置了 `content` 的 `terminal` 调用意味着「卡片上方的描述」;一个设置了 `terminal` 的 generic 调用毫无意义但类型上可表达。类型允许无意义的状态存在。 - 无法表达编辑器最需要的文件工具能力:**diff 卡片**(`{path, oldText, newText}`,Zed 将其渲染为内联 diff / 新文件预览)。`ToolCallPresentation.content` 使用的是 *LLM(大语言模型)* 的 `ContentBlock[]` 词汇(text/image),工具根本无法请求 diff 展示。 -`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」被否决的 Agent Note [折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。 +`packages/core/tools/src/index.ts` 中已有的 `FIXME(tool-presentation)` 指出了修复方向:「重新设计类型,让工具一次性声明其渲染意图(例如按卡片种类的带标签联合类型),而非一堆由 bridge 拼接的可选字段。」一个早先被否决的折叠工具自有呈现提案明确推迟了此事:富渲染「应当在至少有两个真实工具和两个真实消费方验证词汇之后,以带标签 render-intent 联合类型的形式回归。」该条件已由多个生产者族,加上 TUI 与宿主/客户端运行时(Web)这些消费方满足。 ## 决策 @@ -62,7 +62,7 @@ terminal 意图只用于展示。harness 仍通过自身的 bash 服务执行命 ## 曾考虑的替代方案 -- **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其自身的结论正是推迟到两个真实工具和两个真实消费方存在后再做此联合类型,该条件现已满足。 +- **完全删除工具自有的展示**:即本 Agent Note 所取代的那个被否决的 collapse 提案;其自身的结论正是推迟到两个真实工具和两个真实消费方存在后再做此联合类型,该条件现已满足。 - **让 UI 执行 terminal 意图**:否决。这样会绕过 harness 的 bash 策略与归属契约,并把命令执行分裂到不同后端。terminal 卡片描述的是 harness 拥有的执行,绝不授权客户端侧执行。 - **可合并扩展的联合类型**(`ContentBlockMap` 模式):否决。新的渲染意图无论如何需要新的 bridge 代码来渲染,因此一个被 bridge 静默丢弃的插件添加变体,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟糕。 - **保留可选字段集合**:即「问题」一节所剖析的现状:无效状态可表达、字段交互无文档、且完全无法请求 diff 卡片。 @@ -77,6 +77,6 @@ terminal 意图只用于展示。harness 仍通过自身的 bash 服务执行命 ## 相关 -- 取代[折叠工具拥有的 UI 呈现](../../rejected/simplification/2026-06-20-generic-tool-rendering.md)(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。 -- 被[结果时已应用 hunk 差异](2026-07-02-result-time-applied-hunk-diffs.md)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff),叠加在本联合类型的调用时 diff 卡片之上。 +- 取代早先被否决的折叠工具自有呈现提案(已否决——「等两个真实工具和两个真实消费方,然后做带标签 render-intent 联合类型」)中的推迟决定。该条件现已满足;本 Agent Note 即为那个联合类型。 +- 被[结果时已应用 hunk 差异](../../archived/architecture/2026-07-02-result-time-applied-hunk-diffs.md)(已归档)扩展:后者添加了一个持久化的 `meta` 通道,使 write/edit 在结果时输出 `DiffResultView`(应用后的变更:带上下文行的 contextual hunk / 每个 `replace_all` 位点一个,或创建时的整文件 diff)——值/呈现拆分与持久化的 `presentationMeta` 通道现由[规范工具输出契约](2026-07-20-canonical-tool-output-contract.md)拥有。 - 将 `ToolTerminal` 折入当前 UI 传输层使用的带标签 `terminal` 视图。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 6d14977e92..471cf9f92d 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-06-timeout-deadline-library.md: 11d4b8cd48dd345d2324b63e01bd726f12d846b4 -2026-07-06-timeout-deadline-library.zh.md: 334914c689adf54a654c5907395c29ceeeb50891 +2026-07-06-timeout-deadline-library.md: 63463a76a65743436d4e78479800c19e257a42de +2026-07-06-timeout-deadline-library.zh.md: c3d3cdf1c63813fc24c10727e42d326142f3f4de diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 11d4b8cd48..63463a76a6 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -8,7 +8,7 @@ English | [中文](2026-07-06-timeout-deadline-library.zh.md) Timeout handling was drifting apart across the tool-bearing capabilities, and the divergence was not superficial — it was the same logic re-implemented three ways, each with its own subtle correctness burden. -- **bash** ([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts)) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. +- **bash** (then in the bash-local implementation's `run.ts`) had a full, correct timeout inside the process plumbing: a config-clamped `timeoutMs`, two independent triggers — a `killTimer` for the timeout and an `onAbort` listener for upstream cancellation — each calling one `kill()` closure that escalates SIGTERM→grace→SIGKILL on the process group, and two orthogonal outcome booleans (`timedOut`, `aborted`) latched independently. After this consolidation, the plumbing — today [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts)) had a correct but *hand-rolled* timeout: it constructed an `AbortController`, wired `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`, manually added and removed the upstream-signal listener, cleared the timer in a `finally`, and recovered the timeout reason from `signal.reason` in a `translateAbortOrNetwork` helper because the reader surfaces a bare `AbortError`. - **web_search** ([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts)) had **no timeout at all**: `WebSearchRequest` ([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts)) carries no `timeoutMs` field, and each provider's `search()` only forwards `exec.signal`. (web_search stays untimed here — see Consequences.) diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index 334914c689..c3d3cdf1c6 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -8,7 +8,7 @@ Status: implemented 超时处理在各个承载工具的能力之间逐渐分化,而且这种分化并非表面的:同一套逻辑被以三种方式重新实现,各自带有微妙的正确性负担。 -- **bash**([packages/bash/bash-local/src/run.ts](../../../../packages/bash/bash-local/src/run.ts))在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。 +- **bash**(当时位于 bash-local 实现的 `run.ts`)在进程管道内部有一套完整、正确的超时实现:一个经配置钳位的 `timeoutMs`,两个独立触发器(用于超时的 `killTimer` 和用于上游取消的 `onAbort` 监听器),各自调用同一个 `kill()` 闭包对进程组执行 SIGTERM→宽限期→SIGKILL 升级,以及两个正交的结果布尔值(`timedOut`、`aborted`)独立锁存。经此次整合之后,这套管道——今天位于 [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts)——只响应中止;[packages/bash/bash-local/src/index.ts](../../../../packages/bash/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-local/src/provider.ts](../../../../packages/web/web-fetch-local/src/provider.ts))有一套正确但*手写*的超时:构造一个 `AbortController`,连接 `setTimeout(() => controller.abort(new WebError(…, 'WEB_FETCH_TIMEOUT')))`,手动添加和移除上游信号监听器,在 `finally` 中清除定时器,并在 `translateAbortOrNetwork` 辅助函数中从 `signal.reason` 恢复超时原因(因为 reader 只抛出裸 `AbortError`)。 - **web_search**([packages/web/tool-web/src/search.ts](../../../../packages/web/tool-web/src/search.ts))**完全没有超时**:`WebSearchRequest`([packages/web/web/src/types.ts](../../../../packages/web/web/src/types.ts))不携带 `timeoutMs` 字段,各提供方的 `search()` 只转发 `exec.signal`。(web_search 在本次设计中保持无超时——见「后果」。) diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index b25f335819..1904a25158 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.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-10-after-call-compaction-pressure-and-overflow-recovery.md: b934f7fd7087006be4f7eb3659e44e78b8ede367 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 3b5b60a95bef0695a446cdd3d45d299550f449f6 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 51d488db28c57426c75c9ed1cfc90892261c0224 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: ae33cf5c2e944e584cd3d3c6ff76d93619adf7dc diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index b934f7fd70..51d488db28 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -22,9 +22,9 @@ The loop fires awaited serial `agent/post-step(agent, turn, step, signal)` after ### Request recovery is limited to the final model boundary -`RequestError`, `RequestErrorDecision`, and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Each returned stream handle owns a private failure set that preserves the original thrown error identity across dispatch, iterator construction, and iteration without leaking nested-call provenance into an outer call. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, post-step listeners, and cleanup remain ordinary failures. +`RequestError` and the `agent/request-error` waterfall represent failures after the final adapter has been selected. Each returned stream handle owns a private failure set that preserves the original thrown error identity across dispatch, iterator construction, and iteration without leaking nested-call provenance into an outer call. Terminal in-band `error` or `aborted` finishes enter the same path. Prompt assembly, request middleware, request logging, result processing, tools, step listeners, and cleanup remain ordinary failures. -The failed step closes before recovery runs. A retry opens the next numbered step and rebuilds the request from the durable log; consecutive recovery attempts reset only after a successful provider request. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. +The failed step closes before recovery runs. A handling listener repairs durable state, returns `{ kind: 'retry' }`, and stops waterfall delegation. The loop then closes the failed turn and opens one retry turn from the durable log without an intervening idle notification. Retry policy and attempt counts remain plugin-owned; compact-basic clears its per-agent overflow count when the chain reaches terminal `agent/settled`. Both DeepSeek adapters normalize recognized provider context-limit failures to `CONTEXT_WINDOW_EXCEEDED`. The [retry-action decision](../simplification/2026-07-27-request-error-retry-action.md) owns the return boundary. If cancellation lands after assistant tool calls are durable but before all calls dispatch, the loop records a synthetic `tool/call` and aborted `tool/result` pair for every undispatched call before following the normal abort path. The surface therefore never retains orphaned durable tool calls merely because cancellation won the race. @@ -34,7 +34,7 @@ If cancellation lands after assistant tool calls are durable but before all call For `pressure`, compact-basic resolves the durable provider/model target's adapter-owned capacity and exact-target policy, then applies the resulting threshold and retained-tail budgets to one unified `ctx.tokenMeter.measure()` result. Below pressure it returns without pruning. Once pressure qualifies, optional `ctx.toolResultPrune` rewrites oversized current results and compact-basic remeasures through the same meter; safe pressure skips the model call, while remaining pressure selects and summarizes from the pruned surface. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. Common defaults remain threshold ratio `0.8`, retained-history ratio `0.16`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`; optional `modelPolicies` entries override them for an exact provider/model pair. -For canonical overflow, compact-basic requires no capacity metadata and bypasses scalar pressure and the normal retained-token budget. It prunes first, then chooses the maximal tool-balanced head range while leaving the newest indivisible unit and attempts one shrinking summary compaction under the same signal when a range exists. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` whenever pruning or summarization increases it. This remains true when pruning lands before later summary work throws; cancellation still wins. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`. +For canonical overflow, compact-basic requires no capacity metadata and bypasses scalar pressure and the normal retained-token budget. It prunes first, then chooses the maximal tool-balanced head range while leaving the newest indivisible unit and attempts one shrinking summary compaction under the same signal when a range exists. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ kind: 'retry' }` whenever pruning or summarization increases it. This remains true when pruning lands before later summary work throws; cancellation still wins. A backend returning a result without replacement cannot authorize retry, while pruning-only progress can authorize a retry without a `CompactionResult`. `maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws before any replacement all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. A recovery throw after generation advances authorizes retry from durable progress; cancellation or disposal remains authoritative even if recovery work completes concurrently. @@ -42,7 +42,7 @@ The default summarizer resolves explicit configuration, then the latest logged r ## Testing -Unit tests cover final-adapter failure provenance and identity, closed-step retry numbering and reset, cancellation and disposal, post-step ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request. +Unit tests cover final-adapter failure provenance and identity, closed-turn retry numbering and reset, cancellation and disposal, step-boundary ordering, routed-envelope pressure, pressure-gated pruning, pruning-only relief, pruned-input summarization, balanced overflow reduction, durable prune progress before later failure, generation proof, caps, delegation, and auxiliary-call routing. Real-loop tests cover thrown and in-band overflow through pruning or summary compaction to a reconstructed retry request. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index 3b5b60a95b..ae33cf5c2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -22,9 +22,9 @@ Status: implemented ### 请求恢复只覆盖最终模型边界 -`RequestError`、`RequestErrorDecision` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、post-step 监听器与清理仍属于普通失败。 +`RequestError` 与 `agent/request-error` waterfall 表示最终适配器已经选定之后的失败。每个返回的流句柄都绑定一个私有失败集合;该集合在分发、异步迭代器构造与迭代过程中保留原始抛出错误的身份,同时防止把嵌套调用的错误来源误归到外层调用。终止性的带内 `error` 或 `aborted` finish 进入同一路径。提示词装配、请求中间件、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。 -恢复运行前,失败 step 已经关闭。重试会打开下一个编号 step,并从持久日志重建请求;连续恢复尝试计数只在提供方请求成功后重置。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。 +恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall 委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compact-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回边界。 如果取消发生在 assistant 工具调用已经持久化之后、所有调用完成分发之前,循环会为每个尚未分发的调用记录一对合成的 `tool/call` 与 aborted `tool/result`,随后进入正常中止路径。因此,表层不会仅因取消赢得竞态而留下孤立的持久工具调用。 @@ -34,7 +34,7 @@ Status: implemented 对于 `pressure`,compact-basic 先解析持久提供方/模型目标的适配器所属容量与精确目标策略,再把得到的阈值与保留尾部预算应用到一次统一的 `ctx.tokenMeter.measure()` 结果。低于压力时直接返回,不执行剪枝。压力达到条件后,可选的 `ctx.toolResultPrune` 会改写当前表层中过大的工具结果,compact-basic 再通过同一个 meter 重新计量;若压力恢复安全则跳过模型调用,否则从已剪枝表层选择范围并生成摘要。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史比例 `0.16`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`;可选 `modelPolicies` 项可以按精确提供方/模型组合覆盖这些值。 -对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ action: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 +对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它先执行剪枝,再在保留最新不可分割单元的同时选择最大的工具配对平衡头部范围;存在范围时,才在同一 signal 下尝试一次缩小摘要压缩。自动监听器先记录 `session.surface.replaceGeneration`,剪枝或摘要让 generation 增加时就返回 `{ kind: 'retry' }`。即使剪枝先落盘而后续摘要工作抛错,这条规则仍然成立;取消依然优先。后端若只返回结果但没有替换表层,不能授权重试;只有剪枝取得进展时,即使没有 `CompactionResult` 也可以授权重试。 `maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及在任何替换之前恢复抛错,都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。generation 增加后的恢复抛错会基于持久进展授权重试;即使恢复工作并发完成,取消或销毁仍具有最终优先级。 @@ -42,7 +42,7 @@ Status: implemented ## 测试 -单元测试覆盖最终适配器失败的来源与身份、已关闭 step 的重试编号与重置、取消与销毁、post-step 顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。 +单元测试覆盖最终适配器失败的来源与身份、已关闭 turn 的重试编号与重置、取消与销毁、step 边界顺序、已路由信封压力、压力门控剪枝、剪枝独立解除压力、从已剪枝输入生成摘要、平衡溢出缩减、后续失败前已落盘的剪枝进展、generation 证明、上限、委托与辅助调用路由。真实循环测试覆盖抛出式和带内溢出,并验证剪枝或摘要压缩后的重试请求从替换表层重建。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index 3a6b18cecf..c34acd2db1 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-14-provider-routed-llm-adapters.md: 98205d18d07752e0cdba86d7cba80368d45fd816 -2026-07-14-provider-routed-llm-adapters.zh.md: c35225a86baf4c2d09732b5940abbc8046d365fb +2026-07-14-provider-routed-llm-adapters.md: 1bd9197667f6e49c5025c98b4a77500f78595c2b +2026-07-14-provider-routed-llm-adapters.zh.md: 4d57f2cb33ac296500a4a19771ea493621ff93f6 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index 98205d18d0..1bd9197667 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md @@ -28,7 +28,7 @@ A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek` ### Explicit pi-ai provider profiles -`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, and a Harness stream-idle timeout. Provider retry fields are deliberately absent: the adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` owns bounded agent-level recovery. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback. +`dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, a Harness stream-idle timeout, and a provider-owned `retryPolicy`. The adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` executes the resolved policy at the agent failed-step seam. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback. The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index c35225a86b..4d57f2cb33 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md @@ -28,7 +28,7 @@ Status: implemented ### 显式 pi-ai 提供方配置 -`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时和 Harness 流空闲超时。配置中有意不提供重试字段:适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;有界的 agent 层恢复由 `dsh-llm-retry` 负责。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 +`dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、Harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤 seam 上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。 插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml index 52ec59ccbf..89204b0a67 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-15-llm-model-catalog-and-acp-selection.md: adbd8671f0ea5cd2e0c049c32616453882329396 -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: dfcb581e43149281cd18b28f1411ea98944983ad +2026-07-15-llm-model-catalog-and-acp-selection.md: 9edc723b0dfafeaf395eb9325373835138ddbc41 +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 677d9e2a200d488fa9fc27fc2a922dc8f1f871d1 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md index adbd8671f0..9edc723b0d 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md @@ -24,7 +24,7 @@ ACP selection must also preserve the provider dimension. The same model id may a Catalog membership is advisory. It drives selectors and diagnostics but never changes `stream()` routing and never rejects an otherwise valid request. Provider ownership remains exclusive and lifecycle-bound; model ids remain request-time adapter input. -`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` and `deepseek-v4-pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged. +`dsh-llm-pi-ai` maps the configured provider's installed `getModels(provider)` entries into the neutral catalog. Its existing request-time catalog lookup remains authoritative and still rejects unknown models with `UNKNOWN_MODEL`. `dsh-llm-deepseek` accepts an optional `models` config containing display entries, defaulting to `deepseek-v4-flash` named `DeepSeek-V4-Flash` and `deepseek-v4-pro` named `DeepSeek-V4-Pro`. An explicit list replaces those defaults and an empty list disables discovery. The entries improve selector UX for known public or private models, while every unlisted model id continues to pass through unchanged. ### Per-session selection in the front door diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md index dfcb581e43..677d9e2a20 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md @@ -24,7 +24,7 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多 目录成员关系仅提供建议。它驱动选择器与诊断,但不会改变 `stream()` 路由,也不会拒绝原本有效的请求。提供方所有权仍然具有排他性并绑定生命周期;模型 ID 仍是请求时传给适配器的输入。 -`dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含 `deepseek-v4-flash` 和 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。 +`dsh-llm-pi-ai` 将已配置提供方的安装目录 `getModels(provider)` 映射为中立目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受可选的 `models` 配置作为展示条目,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash` 和名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。 ### 前门内的会话级选择 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 158a78acd8..11c3d9b5a2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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-16-explicit-turn-cancellation.md: 7ac743221084e663294954bfd048ba7ef1114f60 -2026-07-16-explicit-turn-cancellation.zh.md: 3dca6339787ebef749c0d6a15609376ede994a97 +2026-07-16-explicit-turn-cancellation.md: 15085a1da2cf183bace9957a4bedb3ea466aa472 +2026-07-16-explicit-turn-cancellation.zh.md: e945b0fea51bdbfee38048573c643b0fb8ecb685 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index 7ac7432210..15085a1da2 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -22,7 +22,7 @@ The driver keeps only a cause-less pre-run marker for queued work cancelled befo The explicit event signatures keep their positional form and place `signal` immediately before a waterfall's final `next`. Prompt submission, request configuration, step-result processing, continuation, and terminal stop join the pre-existing explicit signal seams for pre-step, session prefix, model generation, tool execution, approval, and subagent or workflow requests. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. -`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority, and `agentInterruptReasonOf(signal)` reads only its explicit argument. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. +`ctx.agents` continues to carry only the initiating Agent. Ambient Agent presence does not imply liveness, a current turn, or cancellation authority. The cause reader is private to the loop and states the machine-private slot invariant (only `cancel()` aborts a turn controller, always with a canonical frozen cause) instead of re-validating the reason structurally; no public helper reads a cause off an arbitrary signal. Concurrent Agents isolate both their initiator identities and their turn signals; a child driver shadows the parent initiator while its parent request signal still travels through the subagent seam. Agent disposal requests the runtime-only `{ kind: 'disposed' }` interruption on the active holder. If cancellation already won the controller reason, the reason cannot be rewritten, so terminal classification first checks lifecycle state: disposed wins, then a supported `user` or `parent` cause becomes the coarse aborted outcome, and unrelated exceptions retain the existing error path. ACP cancellation maps to `user`; in-process spawn and fork propagation map to `parent`. Remote ACP subagents retain their existing wire protocol. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index 3dca633978..e945b0fea5 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -22,7 +22,7 @@ AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它 显式事件签名保留位置参数形式,并把 `signal` 放在 waterfall(瀑布式事件)的最后一个参数 `next` 之前。提示词提交、请求配置、步骤结果处理、继续决策和终止停止加入已有的步骤前处理、会话前缀、模型生成、工具执行、审批以及 subagent 或工作流请求的显式 signal seam。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 -`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限,`agentInterruptReasonOf(signal)` 也只读取其显式参数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 +`ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 Agent dispose(资源释放)会在活跃持有者上请求仅用于运行时的 `{ kind: 'disposed' }` 中断。若取消已经先占用控制器的中断原因,该原因便无法改写,因此终态分类会先检查生命周期状态:资源释放结果优先,之后受支持的 `user` 或 `parent` 取消原因形成粗粒度的中止结果,其他异常保留现有错误路径。ACP(Agent Client Protocol)取消映射为 `user`;进程内 spawn 和 fork 的传播映射为 `parent`。远程 ACP subagent 保持现有协议。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index b49e3dcf54..e4b0447cdd 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-gui-web-client-architecture.md: eeae5fb3ad8eb3e9842b497ee51258375760bc93 -2026-07-19-gui-web-client-architecture.zh.md: c6f4b10c2a4c2d210c6bd7a7fa1ac470cab7c0a7 +2026-07-19-gui-web-client-architecture.md: cfc2a7e62358e6282148b2d024ef3b162a903642 +2026-07-19-gui-web-client-architecture.zh.md: b5b082c25f664cfcb0ddd3fcc6c4cd3d58472218 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index eeae5fb3ad..cfc2a7e623 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -30,7 +30,7 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon ## The client cordis tree and the loading chain -The loading chain — the two package kinds (plain vs dshClient plugin), the module-system/plugin-governor split, the two-phase boot over the host-authored entry graph with revisions, and hot reload — is owned by the [client plugin loading RFC](2026-07-23-client-plugin-loading-model.md). The load-bearing facts for this document: the browser boots the same vendored `@cordisjs/plugin-loader` as the host with a client module system (`ctx.modules`, `packages/client/modules`) filling its `internal` seam; every unit with product behavior is an entry in the host-authored `__DSH_BOOT__` graph — all nine plugin packages (infrastructure included) carry the `dshClient` declaration and arrive as fetched `./client` tsdown closure bundles, `immediately` rows differing only in boot phase-one prefetch, while plain packages (react family, cordis, the not-yet-promoted libraries) stay shell-bundled, seeded, and invisible to the graph; bundles execute `window.__ModuleLoader__.load({ id, factory })` and their `require` is answered from the lazy CJS module table (seed words + registered factories, materialized and memoized on first require — cross-plugin value imports are a build error, cooperation goes through cordis services); plugin CSS is inlined in the bundle and injected as ` + +

Café menu

+

Prices include service & tax — updated daily.

+
  • Espresso
  • Flat white
+
DrinkPrice
Espresso€2
Flat white€3
+

See today’s specials.

+ +` + +/** Cordis plugin name. */ +export const name = 'web-fetch-fixture-server' + +/** + * Start the fixture server on 127.0.0.1 and register its shutdown. + * @param ctx - Cordis context; the effect disposes the server with the fiber. + */ +export async function apply(ctx) { + const server = createServer((req, res) => { + if (req.url === '/menu.html') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(PAGE) + return + } + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + res.end('not found') + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(PORT, '127.0.0.1', () => resolve(undefined)) + }) + // The fixture must never hold the process open past protocol shutdown. + server.unref() + ctx.effect(() => async () => { + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve(undefined)) + // Stop accepting first so a connection cannot arrive after the forced close. + server.closeAllConnections() + }) + }, 'web-fetch-fixture-server') +} diff --git a/examples/acp-agent/web.cordis.snapshot.yml b/examples/acp-agent/web.cordis.snapshot.yml new file mode 100644 index 0000000000..015e67e221 --- /dev/null +++ b/examples/acp-agent/web.cordis.snapshot.yml @@ -0,0 +1,31 @@ +# Keyless replay counterpart to web.cordis.yml: the web stack and loopback +# fixture server stay real (the tool call re-executes the actual HTTP fetch and +# markdown rendering); only the model adapter is replaced by replay. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: web + name: '@deepseek-ai/dsh-web' + - id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + - id: web-fetch-fixture + name: './web-fetch-fixture-server.mjs' + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + search: false + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/web.cordis.yml b/examples/acp-agent/web.cordis.yml new file mode 100644 index 0000000000..1ed0b3efba --- /dev/null +++ b/examples/acp-agent/web.cordis.yml @@ -0,0 +1,21 @@ +# Web-fetch composition for the web-fetch snapshot scenario: the web seam, the +# real local HTTP fetch provider, the model-facing web tools (fetch only, so +# the pinned header carries exactly the surface under test), and the loopback +# fixture server the scenario prompt fetches — deterministic content, no +# external network, in recording and replay alike. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: web + name: '@deepseek-ai/dsh-web' + - id: web-fetch-local + name: '@deepseek-ai/dsh-web-fetch-local' + - id: web-fetch-fixture + name: './web-fetch-fixture-server.mjs' + - id: tool-web + name: '@deepseek-ai/dsh-tool-web' + config: + search: false diff --git a/examples/cordis-agent/README.i18n.yaml b/examples/cordis-agent/README.i18n.yaml index 7f4f2ae9dd..f9a03c46fa 100644 --- a/examples/cordis-agent/README.i18n.yaml +++ b/examples/cordis-agent/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: 1309fe9b2935d3224f097ceb2e80501c8075a933 -README.zh.md: 2e3e7d7206d0d676ae7d9c9b3a2c2f8be26aafe7 +# pnpm run verify-translation-pairing --write examples/cordis-agent/README.md +README.md: 55970e932bc16d8361932daa9ea55af83ef73d33 +README.zh.md: c2873b6de96a8b47ad8ea4fb2cf03a7501406300 diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index 1309fe9b29..55970e932b 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which lets the model inspect the current DSH process, mount in-memory temporary Plugins, and unmount them. Temporary Plugins remain active across turns but disappear on unmount, toolset unload, or DSH restart; they create no files or configuration and may affect other sessions in the process. The `ctx.fs` and `ctx.web` services are provider-only capabilities available to those Plugins. The design lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it @@ -10,26 +10,28 @@ The self-referential harness demo: the DeepSeek V4 coding spine on the full-scre # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis +pnpm run demo:cordis # TUI (default) +pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 +pnpm run demo:cordis acp # ACP server ``` The intended demo is staged — verify the listener link first, then let the agent extend itself: ``` -> Mount a plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. +> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] mounted dyn-1 (plugin "status-logger", state: active) + [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the mounted listener firing, live +[cordis:dyn-1] status → … ← the temporary listener firing, live > Now give yourself a reverse_text tool and use it on "harness". [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both. +> Unmount both temporary Plugins. [tool call] cordis_unmount({"id": "dyn-1"}) ``` -Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference the agent writes plugin code against, and try two cooperating mounts (`ctx.provide` in one, `inject` in the other) to watch cordis park and revive the consumer. +Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference used to write Plugin code, and mount two cooperating temporary Plugins (`ctx.provide` in one, `inject` in the other) to watch Cordis park and revive the consumer. ## End-to-end tests -`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two mounts through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. +`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a temporary status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two temporary Plugins through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md index 2e3e7d7206..c2873b6de9 100644 --- a/examples/cordis-agent/README.zh.md +++ b/examples/cordis-agent/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者通过 agent(智能体)所在的 **实时 cordis 运行时** 向模型提供三个工具:检查运行时、将新插件挂载到其中,以及再次释放它们。`ctx.fs` 和 `ctx.web` 服务也会挂载(仅作为提供方,不包含面向模型的文件/Web 工具),使 agent 编写的插件可以构建于真实能力之上;Node 内置模块在沙箱中被截获并重定向到这些服务。设计(沙箱语义、挂载生命周期、跨挂载组合、注意事项)详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时 Plugin,并再次卸载它们。临时 Plugin 可跨 turn 保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他 session。`ctx.fs` 和 `ctx.web` 是这些 Plugin 可用的 provider-only 能力。设计详见[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ## 运行 @@ -10,26 +10,28 @@ # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis +pnpm run demo:cordis # TUI (default) +pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 +pnpm run demo:cordis acp # ACP server ``` 预期演示分阶段进行:先验证监听器链接,再让 agent 扩展自身: ``` -> Mount a plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. +> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] mounted dyn-1 (plugin "status-logger", state: active) + [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the mounted listener firing, live +[cordis:dyn-1] status → … ← the temporary listener firing, live > Now give yourself a reverse_text tool and use it on "harness". [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both. +> Unmount both temporary Plugins. [tool call] cordis_unmount({"id": "dyn-1"}) ``` -请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看为 agent 生成、供其编写插件时参考的服务/事件资料。还可尝试两个协作挂载(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 cordis 如何暂停并恢复消费方。 +请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写 Plugin 代码所用的生成服务/事件资料。还可挂载两个协作临时 Plugin(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 ## 端到端测试 -`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载状态监听器,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个挂载。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 +`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析和 EOF 后干净退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态 listener,测试验证其带标记的 console 行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时 Plugin。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 在每文件 100% 覆盖率门禁下承载单元覆盖。 diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 3cbcb0e571..02d68215f5 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -3,7 +3,7 @@ # Cordis Agent App Composition -The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its own runtime and mount/unmount plugins into it. +The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins. ```mermaid flowchart LR @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_cordis_hmr plugin_cordis_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_cordis_llm_deepseek + plugin_cordis_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_cordis_subprocess plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_cordis_bash plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] @@ -39,6 +41,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `web` | `@deepseek-ai/dsh-web` | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 6144d3ae4d..83144b2153 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -1,9 +1,9 @@ # Self-referential TUI demo: the coding spine plus tools to inspect the live -# service/plugin/tool/mount/API/event state, mount a model-written plugin under -# `cordis-dynamic`, and quiescently unmount it. The app bin loads the gitignored +# service/plugin/tool/temporary/API/event state, mount a model-written temporary +# Plugin, and quiescently unmount it. The app bin loads the gitignored # root `.env` before reading the required DeepSeek key and optional base URL. # Trust stance: the vm and context façade limit accidental global/framework -# access but are not a security boundary; mounted code can reach live capabilities +# access but are not a security boundary; temporary Plugin code reaches live capabilities # such as `ctx.bash`. Grant this toolset like bash access. See # ../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md. @@ -25,6 +25,10 @@ # Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an # ordinary tool whose calls make the mounted listeners observably fire. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -60,7 +64,7 @@ persistenceRoot: './.sessions' workspaceContext: maxBytes: 65536 - welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a listener, or invent a tool for itself.' + welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a temporary listener, or invent a temporary tool for itself.' persona: | You are cordis-agent, a self-referential harness demo powered by the {{model}} model. @@ -68,9 +72,11 @@ You run INSIDE a cordis plugin runtime, and your cordis_* tools operate on that live runtime: cordis_inspect to look around (its `api` and `events` sections document the service methods, type shapes, and events - your plugin code can use), cordis_mount to add a plugin (an event - listener, a brand-new tool for yourself, or a service other mounts - inject), cordis_unmount to clean one up. In mounted code, NEVER use Node + your Plugin code can use), cordis_mount to mount an in-memory temporary + Plugin (an event listener, a brand-new tool for yourself, or a service + another temporary Plugin injects), cordis_unmount to clean one up. These + Plugins remain across turns but disappear on unmount, toolset unload, or + DSH restart and may affect other sessions in this process. In Plugin code, NEVER use Node built-ins (require/setTimeout/fetch) — use the runtime's cordis services via inject: fs, web, bash, and timer (ctx.setTimeout). Prefer small single-purpose plugins, prefer plain notification events over waterfall diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index b5f8e27564..7d9da3f7a2 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -37,16 +37,16 @@ function resultText(result: { content: { type: string; text?: string }[] }): str } describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modifies its own runtime', () => { - it('mounts a status listener whose tagged output actually fires, then unmounts it', async () => { + it('mounts a temporary status listener whose tagged output actually fires, then unmounts it', async () => { ctx = await cordisHarness() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', - text: 'Use cordis_mount to mount a plugin that listens to the \'agent/status\' ' - + 'cordis event and logs every change with console.log. Reply "mounted" once done.', - }]) + text: 'Use cordis_mount to create a temporary Plugin that listens to the \'agent/status\' ' + + 'Cordis event and logs every change with console.log. Reply "running" once done.', + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // The WORLD check: the turn's own running→idle transition must have driven @@ -54,32 +54,32 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif expect(taggedCalls(log).length).toBeGreaterThan(0) const mid = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, + callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, }) expect(resultText(mid)).toContain('dyn-') - agent.followup([{ type: 'text', text: 'Now unmount the plugin you just mounted.' }]) + agent.followup({ content: [{ type: 'text', text: 'Now unmount the temporary Plugin you just mounted.' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const after = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, + callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, }) - expect(resultText(after)).toContain('(no dynamic plugins mounted)') + expect(resultText(after)).toContain('No temporary Plugins are running.') }, 120_000) it('builds itself a reverse_text tool and actually calls it', async () => { ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', - text: 'Give yourself a new tool: use cordis_mount to mount a plugin with ' + text: 'Give yourself a new tool: use cordis_mount to create a temporary Plugin with ' + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' + 'to register a tool named reverse_text with one required string parameter ' + '"text", returning the text reversed. Then CALL reverse_text with the ' + 'exact text "harness" and report its exact output.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // World checks: the tool exists in the registry, was invoked as a real tool call, and its @@ -115,19 +115,19 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif ).toBe(true) }, 120_000) - it('composes two mounts through provide/inject, and unmounting the provider parks the consumer', async () => { + it('composes two temporary Plugins through provide/inject, and unmounting the provider parks the consumer', async () => { ctx = await cordisHarness() const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', - text: 'Mount TWO separate plugins with cordis_mount. First a provider: apply calls ' + text: 'Mount TWO separate temporary Plugins with cordis_mount. First a provider: apply calls ' + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' + 'inject ["shouter", "tools"] that registers (via harness.registerTool + harness.defineTool) ' + 'a tool named shout_text with one required string parameter "text" whose execute returns ' + 'ctx.shouter.shout(args.text) as a text content block. Then CALL shout_text with "quiet" ' + 'and report the exact output.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // World checks: the service is really in the store, the tool really ran. @@ -144,16 +144,16 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif .flatMap(event => event.data.content.filter(block => block.type === 'text').map(block => block.text)) expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - agent.followup([{ type: 'text', text: 'Now unmount ONLY the provider plugin (the one that provided shouter).' }]) + agent.followup({ content: [{ type: 'text', text: 'Now unmount ONLY the provider temporary Plugin (the one that provided shouter).' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // The consumer must have been parked by cordis itself: service gone, - // dependent tool unregistered, dynamic table naming the missing service. + // dependent tool unregistered, temporary section naming the missing service. expect(ctx.get('shouter')).toBeUndefined() expect(ctx.tools.get('shout_text')).toBeUndefined() const after = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'dynamic' }, + callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'temporary' }, }) expect(resultText(after)).toContain('waiting for: shouter') }, 120_000) diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts index 014ce74f7c..2e12cd76c3 100644 --- a/examples/cordis-agent/tests/harness.ts +++ b/examples/cordis-agent/tests/harness.ts @@ -15,8 +15,8 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' const PERSONA = 'You are cordis-agent, a self-referential harness demo. ' + 'Your cordis_* tools operate on the live cordis runtime you run inside: ' - + 'cordis_inspect to look around, cordis_mount to add a plugin, cordis_unmount ' - + 'to clean one up. Follow the tool descriptions exactly and report results briefly.' + + 'cordis_inspect to look around, cordis_mount to mount a temporary Plugin, cordis_unmount ' + + 'to unmount one. Follow the tool descriptions exactly and report results briefly.' export async function cordisHarness(): Promise { const ctx = new Context() diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index e467d32833..53a01260e1 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -10,6 +10,8 @@ flowchart LR cfg["examples/headless-agent
cordis.yml"] plugin_headless_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_headless_llm_deepseek + plugin_headless_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_headless_subprocess plugin_headless_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_headless_bash plugin_headless_cli_agent["cli-agent
@deepseek-ai/dsh-cli-demo"] @@ -54,6 +56,7 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 944fe7e180..896c73469b 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -19,6 +19,10 @@ - id: deepseek-v4-flash contextWindow: 128000 +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/goal.cordis.snapshot.yml b/examples/headless-agent/goal.cordis.snapshot.yml index b853410ef0..f6eeeb05ec 100644 --- a/examples/headless-agent/goal.cordis.snapshot.yml +++ b/examples/headless-agent/goal.cordis.snapshot.yml @@ -1,12 +1,18 @@ -# Replay counterpart to goal.cordis.yml; only the live model is replaced. +# Replay counterpart to goal.cordis.yml. It includes cordis.yml directly because +# a config patch cannot target an entry behind a nested include, then restates +# the goal overlay while replacing the live model with keyless replay. - id: base name: '@cordisjs/plugin-include' config: - path: ./goal.cordis.yml + path: ./cordis.yml patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: + - id: goal + name: '@deepseek-ai/dsh-goal' + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/retry.cordis.snapshot.yml b/examples/headless-agent/retry.cordis.snapshot.yml new file mode 100644 index 0000000000..b8fc79d17f --- /dev/null +++ b/examples/headless-agent/retry.cordis.snapshot.yml @@ -0,0 +1,12 @@ +# Keyless provider-retry composition for the headless stream-json snapshot. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: retry-snapshot-backend + name: './tests/fixtures/retry-snapshot-backend.mjs' diff --git a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml index fc7959e25d..432b64eb9f 100644 --- a/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml +++ b/examples/headless-agent/semantic-checkpoint.cordis.snapshot.yml @@ -17,6 +17,10 @@ file: !!js process.env.DSH_SNAPSHOT_FILE overrideFile: !!js process.env.DSH_SNAPSHOT_OVERRIDE +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index ef84d7309f..bee296333e 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -13,6 +13,7 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' @@ -55,6 +56,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(AgentRegistry) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) + await harness.plugin(LocalSubprocessService) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) await harness.plugin(WorkerCodeRuntime, {}) @@ -114,6 +116,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { const harness = await typedCodeModeHarness() await harness.plugin(LocalTaskService) await harness.plugin(ToolTasks, {}) + await harness.plugin(LocalSubprocessService) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await harness.plugin(ToolBash) return harness @@ -248,7 +251,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { expect(ctx.tasks.list()).toEqual([]) }, 15_000) - it('uses cordis_mount DTO ids directly for active and pending mounts, then confirms removal', async () => { + it('uses cordis_mount DTO ids directly for running and pending temporary Plugins, then confirms removal', async () => { ctx = await typedCodeModeHarness() await ctx.plugin(ToolCordis) @@ -259,14 +262,14 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { const pending = await tools.cordis_mount({ code: "return { name: 'pending-code-mode-plugin', inject: ['missing-code-mode-service'], apply(ctx) {} }", }); - const before = await tools.cordis_inspect({ what: 'dynamic' }); - const unmounted = await tools.cordis_unmount({ id: active.id }); - const after = await tools.cordis_inspect({ what: 'dynamic' }); + const before = await tools.cordis_inspect({ what: 'temporary' }); + const stopped = await tools.cordis_unmount({ id: active.id }); + const after = await tools.cordis_inspect({ what: 'temporary' }); await tools.cordis_unmount({ id: pending.id }); return { active, pending, - unmounted, + stopped, beforeContainsId: before.includes(active.id), afterContainsId: after.includes(active.id), }; @@ -287,7 +290,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { provides: [], waitingFor: ['missing-code-mode-service'], }, - unmounted: { id: 'dyn-1', pluginName: 'active-code-mode-plugin' }, + stopped: { id: 'dyn-1', pluginName: 'active-code-mode-plugin' }, beforeContainsId: true, afterContainsId: false, }) @@ -311,12 +314,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p ctx = await codeModeHarness(workdir) const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'Using one run_code program: run `echo alpha-7` with the bash tool, run `echo beta-9` with the bash tool, ' + 'then write both outputs joined by a plus sign into combined.txt (bash heredoc or redirect), ' + 'and return only the joined string.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events: SessionEvent[] = [...agent.session.events] @@ -363,21 +366,17 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, }) - handle.agent.followup([{ + handle.agent.followup({ content: [{ type: 'text', text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, handle.agent) const events: SessionEvent[] = [...handle.agent.session.events] const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') const outerResult = events.find(event => event.type === 'tool/result') const workspaceContext = events.find(event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && typeof event.data.meta === 'object' - && event.data.meta !== null - && !Array.isArray(event.data.meta) - && event.data.meta.kind === 'workspace-instructions') + && event.data.source.kind === 'workspace-instructions') expect(dispatch).toBeDefined() expect(outerResult).toBeDefined() expect(workspaceContext).toBeDefined() diff --git a/examples/headless-agent/tests/coding-task.e2e.ts b/examples/headless-agent/tests/coding-task.e2e.ts index 4a827858bb..3b7e200582 100644 --- a/examples/headless-agent/tests/coding-task.e2e.ts +++ b/examples/headless-agent/tests/coding-task.e2e.ts @@ -56,12 +56,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-task'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'In the current directory, `node add.test.js` fails because add.js has a bug. ' + 'Fix add.js so the test passes, run `node add.test.js` to verify, and report the result. ' + 'Do not modify add.test.js.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) // The agent claims success… diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index a96b7f3611..48b73c3e6e 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -46,12 +46,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }) const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ + agent.followup({ content: [{ type: 'text', text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a ' + 'time using cat (a separate bash command for each). After reading all four, tell me how ' + 'many files you read and the number mentioned in file1.txt.', - }]) + }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 5238e67374..72aa7b199d 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -1,8 +1,33 @@ import type { Context } from 'cordis' -import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { + CallId, + LlmAdapter, + ReasoningEffortId, + type GenerateOptions, + type LlmResolvedModelInfo, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' + +const HIGH = ReasoningEffortId('high') +const OFF = ReasoningEffortId('off') /** Keyless headless-agent adapter: one real bash call followed by a final answer. */ class CliMockAdapter extends LlmAdapter { + override async resolveModel(provider: string, model: string): Promise { + return { + provider, + id: model, + name: model, + reasoning: { + efforts: [ + { id: OFF, name: 'Off' }, + { id: HIGH, name: 'High' }, + ], + defaultEffort: HIGH, + }, + } + } + async * stream(options: GenerateOptions): AsyncIterable { const toolResult = options.messages.at(-1)?.content.find(block => block.type === 'tool-result') if (toolResult === undefined) { @@ -34,4 +59,8 @@ export const inject = ['llm'] /** Register the keyless `cli-mock` adapter. */ export function apply(ctx: Context): void { ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) + ctx.on('agent/request', async (_agent, _turn, step, _signal, next) => { + const config = await next() + return step === 2 ? { ...config, reasoningEffort: OFF } : config + }) } diff --git a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml index bc9b71685e..d66713526e 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml +++ b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml @@ -2,6 +2,10 @@ - id: cli-mock-llm name: '../cli-mock-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts index 254870eca9..cb0e072d2e 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts +++ b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts @@ -7,7 +7,7 @@ export const name = 'seed-goal' export const inject = ['goals'] export function apply(ctx: Context): void { - ctx.on('agent/pre-step', (agent) => { + ctx.on('agent/step', (agent) => { if (ctx.goals.get(agent) !== undefined) return ctx.goals.create(agent, { objective: 'Prove the composed goal survives in the session log', diff --git a/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs b/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs new file mode 100644 index 0000000000..28dc4f5742 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs @@ -0,0 +1,53 @@ +/** Deterministic provider adapter for the headless retry-policy snapshot. */ + +import { + LlmAdapter, + LlmError, + resolveRetryPolicy, +} from '@deepseek-ai/dsh-llm' + +class RetrySnapshotAdapter extends LlmAdapter { + requests = 0 + firstMessages + policy = resolveRetryPolicy({ + mode: 'normal', + maxRetries: 1, + retryableCodes: ['RATE_LIMIT'], + backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }, 'retry-snapshot-backend.retryPolicy') + + providerRetryPolicy() { + return this.policy + } + + async * stream(options) { + const messages = JSON.stringify(options.messages) + this.requests++ + if (this.requests === 1) { + this.firstMessages = messages + throw new LlmError('snapshot transient failure', 'RATE_LIMIT', { status: 429 }) + } + if (this.requests === 2 && messages !== this.firstMessages) { + throw new Error('retry snapshot changed the model-visible messages') + } + const text = 'RETRY_OK' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +/** Cordis plugin name. */ +export const name = 'retry-snapshot-backend' +/** Required LLM registry service. */ +export const inject = ['llm'] + +/** + * Register the deterministic provider adapter. + * @param {import('cordis').Context} ctx - plugin context carrying the LLM service. + */ +export function apply(ctx) { + ctx.llm.registerAdapter(['deepseek'], new RetrySnapshotAdapter()) +} diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts new file mode 100644 index 0000000000..02be1a9011 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** + * Test driver: start a mock OTLP/HTTP collector, boot the telemetry Loader + * composition against it, run one turn whose prompt carries a fixture + * credential, then persist everything the collector captured to + * `./otlp-captures.json` for the e2e's inspect step. + */ + +import { writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { once } from 'node:events' +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path') + +const captures: unknown[] = [] +const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + captures.push(JSON.parse(Buffer.concat(chunks).toString())) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) +}) +server.listen(0, '127.0.0.1') +await once(server, 'listening') +const address = server.address() +if (address === null || typeof address === 'string') throw new Error('collector has no port') +process.env.DSH_TELEMETRY_E2E_URL = `http://127.0.0.1:${address.port}/v1/logs` + +const ctx = await boot('telemetry-otel-e2e', resolveConfigPath(configPath, undefined)) +try { + // The fixture credential rides the model-visible user message; the exported + // copy must scrub it while the canonical log keeps the original bytes. + await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' }) +} finally { + await ctx.fiber.dispose() +} +await writeFile('./otlp-captures.json', JSON.stringify(captures)) +server.close() +server.closeAllConnections() diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml new file mode 100644 index 0000000000..34e23b828e --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -0,0 +1,28 @@ +# Test-only composition: session-telemetry-otel through the real Loader/app +# path, exporting to the mock OTLP collector the driver starts (url via env). +# The redact-rule entry models a deployment mounting its own scrub rule on the +# telemetry/record waterfall — the seam itself ships no rules. +- id: cli-mock-llm + name: './cli-mock-llm.ts' + +- id: telemetry-redact-rule + name: './telemetry-redact-rule.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + exporter: + url: !!js process.env.DSH_TELEMETRY_E2E_URL + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: cli-mock + model: cli-mock + persona: 'Test the session-telemetry-otel plugin.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts new file mode 100644 index 0000000000..7a2aa7958a --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts @@ -0,0 +1,29 @@ +import type { Context } from 'cordis' + +/** + * Deployment-style redaction rule for the telemetry e2e: scrubs the fixture + * credential from body strings, exactly as a real deployment would mount its + * own rules on the `telemetry/record` waterfall. + */ + +const SECRET = /sk-e2efixture[0-9]+/g +const PLACEHOLDER = '[E2E-REDACTED]' + +function scrub(value: unknown): unknown { + if (typeof value === 'string') return value.replace(SECRET, PLACEHOLDER) + if (Array.isArray(value)) return value.map(scrub) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrub(entry)])) + } + return value +} + +export const name = 'telemetry-redact-rule' + +/** Mount the fixture scrub rule onto the redact waterfall. */ +export function apply(ctx: Context): void { + ctx.on('telemetry/record', (_record, next) => { + const record = next() + return { ...record, body: scrub(record.body) } + }) +} diff --git a/examples/headless-agent/tests/fixtures/time-context.cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml index 91ba8a1254..a105652e9c 100644 --- a/examples/headless-agent/tests/fixtures/time-context.cordis.yml +++ b/examples/headless-agent/tests/fixtures/time-context.cordis.yml @@ -2,6 +2,10 @@ - id: time-context-mock-llm name: './time-context-mock-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/full-loop.e2e.ts b/examples/headless-agent/tests/full-loop.e2e.ts index 4f61ec3fa3..9f50a77937 100644 --- a/examples/headless-agent/tests/full-loop.e2e.ts +++ b/examples/headless-agent/tests/full-loop.e2e.ts @@ -30,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) + agent.followup({ content: [{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index e1edc1dadd..f0cde5c274 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -4,6 +4,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -59,6 +60,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : { models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 6852fed20b..c5a5d3d859 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -24,10 +24,13 @@ const ptyStreamExpected = join(ptyScenarioDir, 'stream-json.expected.jsonl') const ptyConfigPath = fileURLToPath(new URL('../pty.cordis.snapshot.yml', import.meta.url)) const goalScenarioDir = join(snapshotsDir, 'goal-tools') const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url)) +const retryScenarioDir = join(snapshotsDir, 'provider-retry') +const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url)) const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -124,6 +127,86 @@ async function persistedLogs(cwd: string): Promise { } describe('headless stream-json snapshots', () => { + it('retries a transient provider failure through the one-shot app', async () => { + const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry') + const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl') + let runCwd = '' + const result = await runLoaderSmoke({ + label: 'provider retry headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-provider-retry-', + binScript, + configPath: retryConfigPath, + binArgs: ['--config', retryConfigPath, '--output-format', 'stream-json', prompt], + tsconfigPath, + env: { + DSH_SNAPSHOT: 'replay', + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + prepare: (cwd) => { runCwd = cwd }, + inspect: async (cwd) => { + const logs = await persistedLogs(cwd) + expect(logs).toHaveLength(1) + const records = parseJsonl(logs[0]?.content ?? '') + const retries = records.filter(record => record.type === 'llm/retry') + expect(retries).toHaveLength(1) + expect(retries[0]?.data).toMatchObject({ + provider: 'deepseek', + mode: 'normal', + policyKey: '["normal",1,["RATE_LIMIT"],1,1,0]', + retry: 1, + maxRetries: 1, + delayMs: 1, + failure: { message: 'snapshot transient failure', code: 'RATE_LIMIT', status: 429 }, + }) + }, + }) + + expect(result.stderr).toBe('') + const normalized = normalizeHeadlessStream(result.stdout, runCwd) + if (refreshing) await writeFile(streamExpected, normalized) + expect(normalized).toBe(await readFile(streamExpected, 'utf8')) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('logs the model default and a dynamic next-step reasoning effort', async () => { + const result = await runLoaderSmoke({ + label: 'reasoning effort headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-reasoning-effort-', + binScript, + configPath: reasoningConfigPath, + binArgs: ['--config', reasoningConfigPath, '--output-format', 'stream-json', 'prove dynamic reasoning effort'], + tsconfigPath, + }) + + expect(result.stderr).toBe('') + const headers = parseJsonl(result.stdout) + .map(record => record.event) + .filter((event): event is JsonObject => ( + event !== null + && typeof event === 'object' + && !Array.isArray(event) + && 'type' in event + && event.type === 'request/header' + )) + .map((event) => { + const data = event.data as JsonObject + return (data.header as JsonObject).config + }) + expect(headers).toMatchInlineSnapshot(` + [ + { + "model": "cli-mock", + "provider": "cli-mock", + "reasoningEffort": "high", + }, + { + "model": "cli-mock", + "provider": "cli-mock", + "reasoningEffort": "off", + }, + ] + `) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('replays the advanced toolchain through the one-shot app', async () => { const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const fixtureFiles = [ @@ -160,6 +243,7 @@ describe('headless stream-json snapshots', () => { const children = logs.filter(log => typeof log.header.parentSession === 'string') .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt)) const actualSessions = [parent, ...children] + const actualContext = contextFromLogs(actualSessions.map(log => log.content)) if (refreshing) { const harvested = actualSessions.map((log): HarvestedLog => ({ id: String(log.header.id), @@ -176,12 +260,11 @@ describe('headless stream-json snapshots', () => { if (existing === undefined || file === undefined) { throw new Error(`headless snapshot has no fixture for persisted log ${index}`) } - const stable = stabilizeRefreshLog(actual.content, existing, replacements) + const stable = stabilizeRefreshLog(actual.content, existing, replacements, actualContext) await writeFile(file, stable) return stable })) } - const actualContext = contextFromLogs(actualSessions.map(log => log.content)) const expectedContext = contextFromLogs(expectedSessions) for (const [index, actual] of actualSessions.entries()) { const expected = expectedSessions[index] @@ -231,14 +314,16 @@ describe('headless stream-json snapshots', () => { const goalChanges = records.filter((record) => { if (record.type !== 'user/message') return false const data = record.data as JsonObject | undefined - const meta = data?.meta as JsonObject | undefined - return meta?.kind === 'goal/change' + const source = data?.source as JsonObject | undefined + const change = source?.change as JsonObject | undefined + return source?.kind === 'goal' && change?.kind === 'goal/change' }) expect(goalChanges).toHaveLength(1) const data = goalChanges[0]?.data as JsonObject | undefined - const meta = data?.meta as JsonObject | undefined - const goal = meta?.goal as JsonObject | undefined - expect(meta?.operation).toBe('create') + const source = data?.source as JsonObject | undefined + const change = source?.change as JsonObject | undefined + const goal = change?.goal as JsonObject | undefined + expect(change?.operation).toBe('create') expect(goal).toMatchObject({ objective: 'Finish the headless goal-tool snapshot proof', phase: 'active', @@ -354,6 +439,7 @@ describe('headless stream-json snapshots', () => { expect(logs).toHaveLength(1) const actual = logs[0] if (actual === undefined) throw new Error('headless PTY snapshot did not persist its session') + const actualContext = contextFromLogs([actual.content]) if (refreshing) { const harvested: HarvestedLog = { id: String(actual.header.id), @@ -361,10 +447,9 @@ describe('headless stream-json snapshots', () => { content: actual.content, } const replacements = refreshFixtureReplacements([harvested], [expectedSession]) - expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements) + expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext) await writeFile(ptySessionFixture, expectedSession) } - const actualContext = contextFromLogs([actual.content]) const expectedContext = contextFromLogs([expectedSession]) expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext))) diff --git a/examples/headless-agent/tests/resume.e2e.ts b/examples/headless-agent/tests/resume.e2e.ts index a4ded767da..2c9a96e099 100644 --- a/examples/headless-agent/tests/resume.e2e.ts +++ b/examples/headless-agent/tests/resume.e2e.ts @@ -41,7 +41,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses sessionId: SESSION_ID, agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, })).agent - first.followup([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) + first.followup({ content: [{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }], source: { kind: 'user' } }) await waitForIdle(ctx, first) await ctx.fiber.dispose() ctx = undefined @@ -58,7 +58,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) - resumed.followup([{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }]) + resumed.followup({ content: [{ type: 'text', text: 'What was the code I asked you to remember? Reply with just the code.' }], source: { kind: 'user' } }) await waitForIdle(ctx, resumed) // The model recalls it — only possible from the resumed history. diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json index 41072a211a..3a6418d41c 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json @@ -2,6 +2,6 @@ "steps": [ { "op": "initialize" }, { "op": "newSession" }, - { "op": "prompt", "text": "Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK." } + { "op": "prompt", "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." } ] } 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 045b9bb736..9c9dc2c2ce 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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- Calls execute sequentially, even under `Promise.all`.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs 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. Mounts 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 on 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 on unmount) — 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()` VETOES 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 /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs 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. Mounts 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 on 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 on unmount) — 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()` VETOES 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":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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."}},"required":["code"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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":true,"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":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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()` VETOES 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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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()` VETOES 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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} 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 8193973bee..618a6f1af7 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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- Calls execute sequentially, even under `Promise.all`.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs 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. Mounts 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 on 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 on unmount) — 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()` VETOES 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 /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs 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. Mounts 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 on 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 on unmount) — 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()` VETOES 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":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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."}},"required":["code"]}},{"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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":true,"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":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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()` VETOES 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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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()` VETOES 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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 9d2b188a45..996d9a81aa 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,9 +1,9 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-headless","delegationDepth":0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783957884479,"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"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884479,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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\" | \"dynamic\" | \"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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs 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. Mounts 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 on 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 on unmount) — 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()` VETOES 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 /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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 } & Record)[];\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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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 that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `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). 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","dynamic","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 NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs 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. Mounts 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 on 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 on unmount) — 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()` VETOES 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":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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":true,"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":4,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","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 /tmp/advanced-headless.\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()` VETOES 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 /** 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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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 return a task id; collect with `task_output` and stop with `task_kill`. */\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 task and return its id; collect with task_output or stop with task_kill. */\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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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 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: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: 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()` VETOES 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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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":"assistant/chunk","seq":5,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"time":1783950000006,"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":7,"time":1783950000007,"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() {} }\"}"}}}} @@ -11,19 +11,19 @@ {"type":"assistant/chunk","seq":9,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"assistant/chunk","seq":17,"time":1783950000017,"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: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}} +{"type":"assistant/chunk","seq":17,"time":1783950000017,"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":18,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":19,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"tool/call","seq":21,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}} -{"type":"tool/code-dispatch-start","seq":22,"time":1785037378911,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"}}} -{"type":"tool/code-dispatch","seq":23,"time":1785037378912,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}]}} -{"type":"tool/result","seq":24,"time":1785037378916,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"assistant/message","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"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\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":1783957884490,"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":22,"time":1785037378911,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} +{"type":"tool/code-dispatch","seq":23,"time":1785037378912,"data":{"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":24,"time":1785037378916,"data":{"turn":1,"step":2,"callId":"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},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":25,"time":1785037378917,"data":{"turn":1,"step":2}} {"type":"step/start","seq":26,"time":1785037378920,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":27,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -53,7 +53,7 @@ {"type":"assistant/chunk","seq":51,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"} {"type":"tool/call","seq":53,"time":1785037379534,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":54,"time":1785037379535,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} +{"type":"tool/result","seq":54,"time":1785037379535,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":55,"time":1785037379536,"data":{"turn":1,"step":5}} {"type":"step/start","seq":56,"time":1785037379538,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl index 30dea5ebe8..339595168d 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl @@ -1,5 +1,5 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"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"}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Run this advanced flow exactly","messageSeqs":[1],"source":{"kind":"fallback"}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} @@ -10,19 +10,19 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"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: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Run the scripted inspection program\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"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\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":23,"time":0,"data":{"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":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"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},"sourceEventSeqs":[21],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":26,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} @@ -52,7 +52,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":52,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[47,48,49,50,51],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[53],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":55,"time":0,"data":{"turn":1,"step":5}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":56,"time":0,"data":{"turn":1,"step":6}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} diff --git a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl index 16518b2d03..55b4078534 100644 --- a/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl @@ -21,7 +21,7 @@ {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the headless goal-tool snapshot proof\",\"max_goal_rounds\":7}"}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":7},\"activation\":\"armed\"}"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0},"meta":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":23,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the headless goal-tool snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":7},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the headless goal-tool snapshot proof","phase":"active","maxGoalRounds":7},"roundsStarted":0,"createdAt":0,"updatedAt":0}}},"surfaceOp":"append"}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}} {"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} diff --git a/examples/headless-agent/tests/snapshots/provider-retry/input.json b/examples/headless-agent/tests/snapshots/provider-retry/input.json new file mode 100644 index 0000000000..2dc62032a6 --- /dev/null +++ b/examples/headless-agent/tests/snapshots/provider-retry/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { + "op": "prompt", + "text": "retry the transient provider failure" + } + ] +} diff --git a/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl new file mode 100644 index 0000000000..d595b8ab6a --- /dev/null +++ b/examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl @@ -0,0 +1,19 @@ +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"}},"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[1],"source":{"kind":"fallback"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"llm/retry","seq":6,"time":0,"data":{"turn":1,"step":1,"provider":"deepseek","mode":"normal","policyKey":"[\"normal\",1,[\"RATE_LIMIT\"],1,1,0]","retry":1,"maxRetries":1,"delayMs":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"snapshot transient failure","code":"RATE_LIMIT","status":429}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":8,"time":0,"data":{"turn":2,"trigger":{"kind":"retry"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":9,"time":0,"data":{"turn":2,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RETRY_OK"}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RETRY_OK"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":4,"outputTokens":2}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":15,"time":0,"data":{"turn":2,"step":1,"content":[{"type":"text","text":"RETRY_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":4,"outputTokens":2}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":2,"step":1}}} +{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":17,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}}} +{"type":"result","success":true,"sessionId":"{{sessionId}}","turn":2,"result":"RETRY_OK","reason":{"kind":"completed"},"usage":{"inputTokens":4,"outputTokens":2}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl index 99eaf6e4ee..9e2aa427d3 100644 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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":true,"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":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","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":"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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 return a task id; collect with `task_output` and stop with `task_kill`.","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 task and return its id; collect with task_output or stop with task_kill."}},"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. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active 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":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":6,"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":7,"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\"}"}}}} diff --git a/examples/headless-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts index c3053572d9..9a9ccb72cc 100644 --- a/examples/headless-agent/tests/todo-write.e2e.ts +++ b/examples/headless-agent/tests/todo-write.e2e.ts @@ -28,10 +28,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - agent.followup([{ type: 'text', text: + agent.followup({ content: [{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' + '"inspect the failing test" (in_progress), then "apply the fix" (pending). ' - + 'Send both in one todo_write call, then reply with the single word DONE.' }]) + + 'Send both in one todo_write call, then reply with the single word DONE.' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const events = [...agent.session.events] diff --git a/examples/jsonrpc-agent/cordis.snapshot.yml b/examples/jsonrpc-agent/cordis.snapshot.yml new file mode 100644 index 0000000000..28d17c9b3d --- /dev/null +++ b/examples/jsonrpc-agent/cordis.snapshot.yml @@ -0,0 +1,28 @@ +# Keyless replay includes the live `cordis.yml`, disables the key-requiring +# DeepSeek adapter, and inserts `llm-replay` to serve recorded JSONL without a +# key or network; every other entry remains shared. The replay provider +# catalog claims the `deepseek` provider so the SDK server's `initialize` +# finds it owned and never mounts the real-adapter fallback. The SDK snapshot +# suite passes this path explicitly through `DSH_CORDIS_CONFIG` (the +# jsonrpc-demo bin performs no DSH_SNAPSHOT config swap of its own), and +# `llm-replay` reads `DSH_SNAPSHOT_FILE` / `DSH_SNAPSHOT_CHILD_FILES` from the +# harness. Stdout remains reserved for JSON-RPC frames. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + # `name` asserts the target: a mismatch skips the patch and warns only + # when a logger exists. + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek + name: DeepSeek + models: + - id: deepseek-v4-flash diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index f0eb7ea6d8..b23dd30b4a 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -17,6 +17,10 @@ thinking: enabled reasoningEffort: max +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: @@ -38,6 +42,8 @@ name: '@deepseek-ai/dsh-session-persistence-jsonl' config: root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + # Snapshot runs read the raw JSONL back; production keeps zstd frames. + compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" - id: session-checkpoints name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts new file mode 100644 index 0000000000..30f480064e --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts @@ -0,0 +1,32 @@ +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' + +/** + * Scripted model for the CHILD runtime: answers every request with its own + * process cwd, so the driving e2e can prove the parent session's workspace + * reached the child process across the SDK wire. `options` carries the + * request; the reply depends only on process state. + */ +class CwdEchoAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + void options + const reply = `child cwd: ${process.cwd()}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 3, outputTokens: reply.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'child-mock-llm' +export const inject = ['llm'] + +/** + * Register the cwd-echo adapter under the `mock` provider. + * @param ctx - the plugin context supplying `ctx.llm`. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new CwdEchoAdapter()) +} diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child.cordis.yml b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child.cordis.yml new file mode 100644 index 0000000000..bde4288a34 --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child.cordis.yml @@ -0,0 +1,40 @@ +# The CHILD runtime for the SDK subagent composition test: a complete +# stdio JSON-RPC harness whose scripted model echoes its process cwd. The +# parent's subagent-sdk backend spawns this composition per run; stdout is +# reserved for JSON-RPC frames. +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +- id: child-mock-llm + name: './child-mock-llm.ts' + +- id: agent-core + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + persona: 'Echo where you run.' + workspaceContext: false + skills: + enabled: false + toolBash: + enableRunInBackground: false + toolTasks: false + +# The child persists its own session log beside the parent's (distinct root), +# so the driving e2e can inspect both transcripts after the run. +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.child-sessions' + compression: none + +- id: session-checkpoints + name: '@deepseek-ai/dsh-session-checkpoint-policy' + +# bash-local executes through the subprocess seam. +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml new file mode 100644 index 0000000000..817196414c --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml @@ -0,0 +1,43 @@ +# Test-only composition: the SDK subagent backend on the real Loader/app path. +# The scripted model delegates once; the child — a COMPLETE second harness +# runtime speaking stdio JSON-RPC — echoes its process cwd, so parent-session +# cwd inheritance is asserted keylessly end to end across the SDK wire. +# `cwd` is deliberately omitted — the inheritance branch under test. The child +# launch is machine-absolute, so the driving e2e supplies it via +# DSH_TEST_CHILD_COMMAND / DSH_TEST_CHILD_ARGS / DSH_TEST_CHILD_ENV (resolved +# through the shared example-launch resolver, per testing policy). +- id: mock-llm + name: './mock-delegating-llm.ts' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +# providerName is omitted: the composition exercises the shipped default +# (`dsh-sdk`) through the real Loader. +- id: subagent-dsh-sdk + name: '@deepseek-ai/dsh-subagent-dsh-sdk' + config: + command: !!js process.env.DSH_TEST_CHILD_COMMAND + args: !!js JSON.parse(process.env.DSH_TEST_CHILD_ARGS ?? '[]') + provider: mock + model: mock-echo + env: !!js JSON.parse(process.env.DSH_TEST_CHILD_ENV ?? '{}') + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: dsh-sdk + toolName: subagent + # The SDK backend advertises no depthLimit: the child harness owns its own + # recursion budget, so the local numeric default cannot apply here. + maxDepth: 'provider-managed' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: mock + model: mock-delegate + persona: 'Test SDK subagent cwd inheritance.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts new file mode 100644 index 0000000000..34412c1839 --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +/** Test driver: one delegation turn through a headless Loader composition. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('sdk-subagent cwd driver requires a config path') + +const ctx = await boot('sdk-subagent-cwd-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'delegate' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts new file mode 100644 index 0000000000..e5ce753fd8 --- /dev/null +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts @@ -0,0 +1,48 @@ +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' + +/** + * Test adapter for the `mock-delegate` model: the first request calls the + * `subagent` tool once, and the follow-up streams the tool result text back + * verbatim — so the SDK child runtime's answer (the scripted child model's + * cwd echo) reaches the parent session log for the driving e2e to assert. + */ +class MockDelegatingAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const toolResultText = options.messages.at(-1)?.content + .filter(block => block.type === 'tool-result') + .flatMap(block => block.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') ?? '' + + if (toolResultText.length === 0) { + const args = JSON.stringify({ description: 'cwd probe', prompt: 'report your workspace' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: CallId('call-delegate'), name: 'subagent', argumentsDelta: args } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-delegate'), name: 'subagent', arguments: args } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + + const reply = `child reported:\n${toolResultText}` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: reply } + yield { type: 'block-end', index: 0, block: { type: 'text', text: reply } } + yield { type: 'usage', usage: { inputTokens: 10, outputTokens: reply.length } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'mock-llm' +export const inject = ['llm'] + +/** + * Register the delegating mock adapter under the `mock` provider. + * @param ctx - the plugin context supplying `ctx.llm`. + */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['mock'], new MockDelegatingAdapter()) +} diff --git a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts index cb2ab9687e..0d4e4d8f2e 100644 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ b/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts @@ -1,4 +1,3 @@ -import { spawn } from 'node:child_process' import { createServer } from 'node:http' import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -6,6 +5,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { execa } from 'execa' import { describe, expect, it } from 'vitest' const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) @@ -69,7 +69,9 @@ describe('jsonrpc-agent keyless smoke', () => { await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) const address = modelServer.address() if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') - const child = spawn(process.execPath, [ + // The line-predicate protocol driving below is the genuinely custom part; + // execa owns spawn, the deadline, and exit settlement around it. + const child = execa(process.execPath, [ '--import', 'tsx', binScript, @@ -77,27 +79,26 @@ describe('jsonrpc-agent keyless smoke', () => { ], { cwd: repoRoot, env: { - ...process.env, DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, DSH_CWD: root, DSH_SESSION_ROOT: join(root, '.sessions'), ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), }, - stdio: ['pipe', 'pipe', 'pipe'], + timeout: 35_000, + killSignal: 'SIGKILL', + reject: false, }) const lines: string[] = [] let stdoutBuffer = '' let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdoutBuffer += chunk + child.stdout.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf8') const parts = stdoutBuffer.split('\n') stdoutBuffer = parts.pop() ?? '' lines.push(...parts) }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) + child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) try { child.stdin.write(`${JSON.stringify({ @@ -144,16 +145,8 @@ describe('jsonrpc-agent keyless smoke', () => { child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} }) - if (child.exitCode === null) { - await new Promise((resolve, reject) => { - child.once('exit', (code) => { - if (code === 0) resolve() - else reject(new Error(`runtime exited ${code}; stderr=${stderr}`)) - }) - }) - } else { - expect(child.exitCode, stderr).toBe(0) - } + const exit = await child + expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0) const sessionsRoot = join(root, '.sessions') const files = await readdir(sessionsRoot, { recursive: true }) const log = files.find(file => file.endsWith('.jsonl.zstd')) @@ -162,14 +155,16 @@ describe('jsonrpc-agent keyless smoke', () => { expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' }) } finally { - if (child.exitCode === null) child.kill('SIGKILL') + // No-op after exit; reject: false settles on every outcome, so cleanup never races teardown. + child.kill('SIGKILL') + await child await new Promise(resolve => modelServer.close(() => { resolve() })) await rm(root, { recursive: true, force: true }) } }, 40_000) it('rejects an invalid max-token success env value', async () => { - const child = spawn(process.execPath, [ + const { exitCode, stdout, stderr } = await execa(process.execPath, [ '--import', 'tsx', binScript, @@ -177,26 +172,17 @@ describe('jsonrpc-agent keyless smoke', () => { ], { cwd: repoRoot, env: { - ...process.env, DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - - const exitCode = await new Promise((resolve, reject) => { - child.once('error', reject) - child.once('exit', resolve) + stdin: 'ignore', + timeout: 25_000, + killSignal: 'SIGKILL', + reject: false, }) expect(exitCode, stderr).toBe(1) expect(stdout).toBe('') expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc') - }, 10_000) + }, 30_000) }) diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts new file mode 100644 index 0000000000..26254f0cf1 --- /dev/null +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -0,0 +1,294 @@ +/** + * Keyless snapshot coverage for the TypeScript SDK path: each scenario spawns + * the REAL `dsh-jsonrpc-agent` runtime (per `DSH_EXAMPLE_MODE`) through the + * REAL `@deepseek-ai/dsh-sdk-client`, drives one turn over stdio JSON-RPC, + * and pins three surfaces — the SDK `TurnResult`, the complete notification + * stream, and the persisted session logs. Replay serves recorded model + * responses via `llm-replay` (`cordis.snapshot.yml`); `DSH_SNAPSHOT=record` + * re-records against the live API; `DSH_SNAPSHOT=refresh` replays committed + * fixtures and rewrites expected outputs. + */ + +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + normalizeSessionLog, + normalizeStdout, + refreshFixtureReplacements, + scrubRequestHeaders, + stabilizeRefreshLog, + type HarvestedLog, + type NormalizeContext, +} from '@deepseek-ai/dsh-acp-snapshot' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' +import { DeepSeekHarness, type HarnessNotification, type TurnResult } from '@deepseek-ai/dsh-sdk-client' + +const testsDir = dirOf(import.meta.url) +const snapshotsDir = join(testsDir, 'snapshots') +const liveConfig = join(testsDir, '..', 'cordis.yml') +const replayConfig = join(testsDir, '..', 'cordis.snapshot.yml') +const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) + +const mode = process.env.DSH_SNAPSHOT ?? 'replay' +const recording = mode === 'record' +const refreshing = mode === 'refresh' + +function dirOf(url: string): string { + return fileURLToPath(new URL('.', url)) +} + +interface SdkScenario { + /** Scenario name; the snapshots/ fixture directory. */ + name: string + /** The user prompt for the single SDK turn. */ + prompt: string + /** Fixed SDK session id, so fixtures and replay binding stay stable. */ + sessionId: string + /** How many child sessions the turn persists (subagent scenarios). */ + children: number +} + +const SCENARIOS: SdkScenario[] = [ + { + name: 'text-turn', + prompt: 'Reply with exactly: SDK snapshot OK', + sessionId: 'sdk-snapshot-text', + children: 0, + }, + { + name: 'bash-tool', + prompt: 'Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391', + sessionId: 'sdk-snapshot-bash', + children: 0, + }, + { + name: 'subagent-spawn', + prompt: "Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim.", + sessionId: 'sdk-snapshot-subagent', + children: 1, + }, +] + +interface PersistedLog { + readonly path: string + readonly content: string + readonly header: Record +} + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { recursive: true }) + return entries.filter(entry => entry.endsWith('.jsonl')).map(entry => join(dir, entry)).sort() +} + +async function persistedLogs(sessionsRoot: string): Promise { + const files = await jsonlFiles(sessionsRoot) + return Promise.all(files.map(async (path) => { + const content = await readFile(path, 'utf8') + const header = JSON.parse(content.slice(0, content.indexOf('\n'))) as Record + return { path, content, header } + })) +} + +function contextOf(logs: readonly { content: string; header: Record }[], cwd: string): NormalizeContext { + return { + sessionIds: logs.flatMap(log => typeof log.header.id === 'string' ? [log.header.id] : []), + cwd, + } +} + +function contextOfContents(contents: readonly string[]): NormalizeContext { + const headers = contents.map(content => JSON.parse(content.slice(0, content.indexOf('\n'))) as Record) + return { + sessionIds: headers.flatMap(header => typeof header.id === 'string' ? [header.id] : []), + cwd: typeof headers[0]?.cwd === 'string' ? headers[0].cwd : '\0no-cwd\0', + } +} + +/** + * Normalize the SDK-visible notification stream: embedded `session.event` + * envelopes get the session-log treatment (times zeroed, headers tokenized), + * then every record is scrubbed like a wire frame. + */ +function normalizeNotifications(notifications: readonly HarnessNotification[], ctx: NormalizeContext): string { + const events = notifications + .filter(n => n.method === 'session.event') + .map(n => n.params.event as Record) + const normalizedEvents = events.length === 0 + ? [] + : scrubRequestHeaders(normalizeSessionLog( + `${events.map(event => JSON.stringify(event)).join('\n')}\n`, + ctx, + )).trimEnd().split('\n').map(line => JSON.parse(line) as Record) + let eventIndex = 0 + const records = notifications.map((notification) => { + if (notification.method !== 'session.event') return { method: notification.method, params: notification.params } + const event = normalizedEvents[eventIndex++] + return { method: notification.method, params: { ...notification.params, event } } + }) + return normalizeStdout(`${records.map(record => JSON.stringify(record)).join('\n')}\n`, ctx) +} + +/** Normalize the turn-result projection (status, reason kind, final text). */ +function normalizeResult(result: TurnResult, ctx: NormalizeContext): string { + return normalizeStdout(`${JSON.stringify({ + status: result.status, + reason: result.reason, + finalResponse: result.finalResponse, + })}\n`, ctx) +} + +/** One SDK turn against a fresh runtime subprocess in an isolated cwd. */ +async function runScenario(scenario: SdkScenario): Promise<{ + result: TurnResult + notifications: HarnessNotification[] + logs: PersistedLog[] + cwd: string +}> { + const cwd = await mkdtemp(join(tmpdir(), `sdk-snapshot-${scenario.name}-`)) + const sessionsRoot = join(cwd, '.sessions') + const scenarioDir = join(snapshotsDir, scenario.name) + const launch = resolveExampleLaunch({ + srcBin: runtimeBin, + configArgs: [], + tsconfigPath: repoTsconfig, + }) + const childFixtures = Array.from( + { length: scenario.children }, + (_, index) => join(scenarioDir, `session.${index + 1}.jsonl`), + ) + const env: Record = { + ...Object.fromEntries(Object.entries(process.env).filter(([, value]) => value !== undefined)) as Record, + ...Object.fromEntries(Object.entries(launch.env).filter(([, value]) => value !== undefined)) as Record, + DSH_CORDIS_CONFIG: recording ? liveConfig : replayConfig, + DSH_SESSION_ROOT: sessionsRoot, + DSH_CWD: cwd, + DSH_SNAPSHOT: mode, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + ...recording ? {} : { + DSH_SNAPSHOT_FILE: join(scenarioDir, 'session.jsonl'), + ...childFixtures.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: childFixtures.join(delimiter) } : {}, + }, + } + + const harness = new DeepSeekHarness({ + launch: { + command: launch.command, + args: launch.args, + cwd, + env, + requestTimeoutMs: 110_000, + }, + cwd, + provider: 'deepseek', + model: 'deepseek-v4-flash', + }) + try { + const notifications: HarnessNotification[] = [] + const result = await harness.run(scenario.prompt, { + sessionId: scenario.sessionId, + onNotification: (notification) => { notifications.push(notification) }, + }) + await harness.close() + const logs = await persistedLogs(sessionsRoot) + return { result, notifications, logs, cwd } + } finally { + await harness.close() + await rm(cwd, { recursive: true, force: true }) + } +} + +/** Order logs parent-first, children by creation time (fixture layout order). */ +function orderLogs(logs: PersistedLog[], scenario: SdkScenario): PersistedLog[] { + const parents = logs.filter(log => typeof log.header.parentSession !== 'string') + const children = logs.filter(log => typeof log.header.parentSession === 'string') + .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt)) + expect(parents).toHaveLength(1) + expect(children).toHaveLength(scenario.children) + return [...parents, ...children] +} + +function fixtureFiles(scenario: SdkScenario): string[] { + const dir = join(snapshotsDir, scenario.name) + return [ + join(dir, 'session.jsonl'), + ...Array.from({ length: scenario.children }, (_, index) => join(dir, `session.${index + 1}.jsonl`)), + ] +} + +describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { + for (const scenario of SCENARIOS) { + it(`replays ${scenario.name} through the SDK`, async () => { + const scenarioDir = join(snapshotsDir, scenario.name) + const notificationsExpectedPath = join(scenarioDir, 'notifications.expected.jsonl') + const resultExpectedPath = join(scenarioDir, 'result.expected.json') + + const { result, notifications, logs, cwd } = await runScenario(scenario) + const ordered = orderLogs(logs, scenario) + const actualContext = contextOf(ordered, cwd) + + if (recording) { + // Fixtures carry tokenized request headers; llm-replay reads only + // assistant output and tool traffic, so scrubbing keeps prompts and + // schemas out of the corpus without affecting replay. + await mkdir(scenarioDir, { recursive: true }) + await Promise.all(ordered.map(async (log, index) => { + const file = fixtureFiles(scenario)[index] + if (file === undefined) throw new Error(`no fixture path for persisted log ${index}`) + await writeFile(file, scrubRequestHeaders(log.content)) + })) + } + + const files = fixtureFiles(scenario) + let expectedContents = await Promise.all(files.map(file => readFile(file, 'utf8'))) + + if (refreshing) { + const harvested = ordered.map((log): HarvestedLog => ({ + id: String(log.header.id), + createdAt: Number(log.header.createdAt), + ...typeof log.header.parentSession === 'string' ? { parentSession: log.header.parentSession } : {}, + content: log.content, + })) + const replacements = refreshFixtureReplacements(harvested, expectedContents) + expectedContents = await Promise.all(ordered.map(async (log, index) => { + const existing = expectedContents[index] + const file = files[index] + if (existing === undefined || file === undefined) throw new Error(`no fixture for persisted log ${index}`) + const stable = stabilizeRefreshLog(log.content, existing, replacements, actualContext) + await writeFile(file, stable) + return stable + })) + } + + // Persisted transcripts match the committed fixtures. + const expectedContext = contextOfContents(expectedContents) + for (const [index, log] of ordered.entries()) { + const expected = expectedContents[index] + if (expected === undefined) throw new Error(`no fixture for persisted log ${index}`) + expect(scrubRequestHeaders(normalizeSessionLog(log.content, actualContext))) + .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext))) + } + + // The SDK-visible wire stream and turn result match their expected outputs. + const normalizedNotifications = normalizeNotifications(notifications, actualContext) + const normalizedResult = normalizeResult(result, actualContext) + if (recording || refreshing) { + await writeFile(notificationsExpectedPath, normalizedNotifications) + await writeFile(resultExpectedPath, normalizedResult) + } + expect(normalizedNotifications).toBe(await readFile(notificationsExpectedPath, 'utf8')) + expect(normalizedResult).toBe(await readFile(resultExpectedPath, 'utf8')) + + // Wire-shape invariants that must hold in every mode. + expect(result.status).toBe('ok') + expect(notifications.at(-1)?.method).toBe('session.finished') + if (scenario.children > 0) { + expect(notifications.some(n => n.method === 'subagent.started')).toBe(true) + expect(notifications.some(n => n.method === 'subagent.finished')).toBe(true) + } + }) + } +}) diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl new file mode 100644 index 0000000000..3ec8035656 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl @@ -0,0 +1,97 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391"}],"source":{"kind":"user"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Run this exact command with","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" its"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" only"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"{"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"command"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":": "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"echo"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" d"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"sh"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-s"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"dk"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-proof"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"-"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"739"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"1"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":", "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"description"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":": "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"Run"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" the"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" echo"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" command"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" as"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":" requested"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","argumentsDelta":"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific bash command and reply with its stdout only."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":59,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific bash command and reply with its stdout only."},{"type":"tool-call","id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":60,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":61,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_Ry17evSfTr0uJnHhg3X93070","content":[{"type":"text","text":"dsh-sdk-proof-7391\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":62,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":63,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" produced"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'ll"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":74,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":75,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":76,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":77,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":78,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stdout"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":79,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":80,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":81,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"d"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":82,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"sh"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":83,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-s"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":84,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"dk"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":85,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-proof"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":86,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"-"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":87,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"739"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":88,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"1"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":89,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":90,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"dsh-sdk-proof-7391"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":91,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":92,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":93,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."},{"type":"text","text":"dsh-sdk-proof-7391"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":94,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":95,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/bash-tool/result.expected.json new file mode 100644 index 0000000000..42b553d4a3 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/result.expected.json @@ -0,0 +1 @@ +{"status":"ok","reason":{"kind":"completed"},"finalResponse":"dsh-sdk-proof-7391"} diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl new file mode 100644 index 0000000000..dc2c7ee102 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl @@ -0,0 +1,30 @@ +{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"/tmp/sdk-snapshot-bash-tool-ywbuab","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785097395904,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785097395905,"data":{"content":[{"type":"text","text":"Run this exact command with your bash tool, then reply with its stdout only: echo dsh-sdk-proof-7391"}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785097395907,"data":{"title":"Run this exact command with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785097395908,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785097395909,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785097396437,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785097396438,"data":{"turn":1,"step":1,"index":0,"dt":[219,22,1,0,0,0,1,24,25,0,0,25,1,24,1,0],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," bash"," command"," and"," reply"," with"," its"," stdout"," only","."]}} +{"type":"assistant/chunk","seq":23,"time":1785097396856,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":24,"time0":1785097396857,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,1,0,0,25,0,0,0,0,1,24,0,0,1,24,1,25,0,0,0,25,0,0,25,1,0,0,25],"id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," d","sh","-s","dk","-proof","-","739","1","\"",", ","\"","description","\"",": ","\"","Run"," the"," echo"," command"," as"," requested","\"","}"]}} +{"type":"assistant/chunk","seq":55,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a specific bash command and reply with its stdout only."}}}} +{"type":"assistant/chunk","seq":56,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}}}} +{"type":"assistant/chunk","seq":57,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":58,"time":1785097397114,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":59,"time":1785097397118,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific bash command and reply with its stdout only."},{"type":"tool-call","id":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"} +{"type":"tool/call","seq":60,"time":1785097397119,"data":{"turn":1,"step":1,"callId":"call_00_Ry17evSfTr0uJnHhg3X93070","name":"bash","arguments":"{\"command\": \"echo dsh-sdk-proof-7391\", \"description\": \"Run the echo command as requested\"}"}} +{"type":"tool/result","seq":61,"time":1785097397142,"data":{"turn":1,"step":1,"callId":"call_00_Ry17evSfTr0uJnHhg3X93070","content":[{"type":"text","text":"dsh-sdk-proof-7391\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1785097397145,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":63,"time":1785097397145,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":64,"time":1785097398036,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":65,"time0":1785097398037,"data":{"turn":1,"step":2,"index":0,"dt":[218,25,1,0,24,1,0,0,25,0,0,26,1,0],"texts":["The"," command"," produced"," the"," expected"," output","."," I","'ll"," reply"," with"," just"," that"," stdout","."]}} +{"type":"assistant/chunk","seq":80,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":81,"time0":1785097398358,"data":{"turn":1,"step":2,"index":1,"dt":[0,24,0,0,0,1,0],"texts":["d","sh","-s","dk","-proof","-","739","1"]}} +{"type":"assistant/chunk","seq":89,"time":1785097398408,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."}}}} +{"type":"assistant/chunk","seq":90,"time":1785097398408,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"dsh-sdk-proof-7391"}}}} +{"type":"assistant/chunk","seq":91,"time":1785097398409,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}}}} +{"type":"assistant/chunk","seq":92,"time":1785097398409,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":93,"time":1785097398409,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."},{"type":"text","text":"dsh-sdk-proof-7391"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92],"surfaceOp":"append"} +{"type":"step/end","seq":94,"time":1785097398411,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":95,"time":1785097398412,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl new file mode 100644 index 0000000000..b403171d1d --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl @@ -0,0 +1,175 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim."}],"source":{"kind":"user"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Use"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" description"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" probe"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" step"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" step"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"{"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"description"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":": "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"echo"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" probe"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":", "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":74,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"prom"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":75,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"pt"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":76,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":77,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":": "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":78,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":79,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"Reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":80,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":81,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":82,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":":"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":83,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":84,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":85,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":86,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":87,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":88,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":89,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","argumentsDelta":"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":90,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once with description 'echo probe' and prompt 'Reply with exactly: child answer 42.'\n2. Then reply with the subagent's final answer verbatim.\n\nLet me do this step by step."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":91,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":92,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":93,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":94,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once with description 'echo probe' and prompt 'Reply with exactly: child answer 42.'\n2. Then reply with the subagent's final answer verbatim.\n\nLet me do this step by step."},{"type":"tool-call","id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":95,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}}}} +{"method":"subagent.started","params":{"parentSessionId":"{{sessionId}}","childSessionId":"{{sessionId}}"}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly: child answer","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"subagent.finished","params":{"provider":"spawn","agentId":"{{sessionId}}","parentSessionId":"{{sessionId}}","childSessionId":"{{sessionId}}","status":"ok","stopReason":"completed","lastAssistantMessage":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}]}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":96,"time":0,"data":{"turn":1,"step":1,"callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","content":[{"type":"text","text":"child answer 42."}],"isError":false},"sourceEventSeqs":[95],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":97,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":98,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":99,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":100,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":101,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":102,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":103,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" replied"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":104,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":105,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":106,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":107,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":108,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":109,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":110,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":111,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":112,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":113,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":114,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":115,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":116,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":117,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":118,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":119,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":120,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"'s"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":121,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" final"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":122,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":123,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":124,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":125,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":126,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":127,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":128,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":129,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":130,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":131,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":132,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent replied with \"child answer 42.\" Now I need to reply with the subagent's final answer verbatim."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":133,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":134,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":135,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":136,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent replied with \"child answer 42.\" Now I need to reply with the subagent's final answer verbatim."},{"type":"text","text":"child answer 42."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}},"sourceEventSeqs":[99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":137,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":138,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/result.expected.json new file mode 100644 index 0000000000..b83a869dee --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/result.expected.json @@ -0,0 +1 @@ +{"status":"ok","reason":{"kind":"completed"},"finalResponse":"child answer 42."} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl new file mode 100644 index 0000000000..531cc19c6f --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":0,"id":"0b7fd85c-9f6f-4d46-b954-363984ce66fb","createdAt":1785097410282,"cwd":"/tmp/sdk-snapshot-subagent-spawn-6fzuBd","parentSession":"sdk-snapshot-subagent","delegationDepth":1} +{"type":"turn/start","seq":0,"time":1785097410283,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785097410283,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785097410283,"data":{"title":"Reply with exactly: child answer","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785097410284,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785097410284,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785097410836,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785097410836,"data":{"turn":1,"step":1,"index":0,"dt":[149,26,0,0,24,1,0,0,0,25,0,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} +{"type":"assistant/chunk","seq":20,"time":1785097411113,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":21,"time0":1785097411113,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,0,0],"texts":["child"," answer"," ","42","."]}} +{"type":"assistant/chunk","seq":26,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}} +{"type":"assistant/chunk","seq":27,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} +{"type":"assistant/chunk","seq":28,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":29,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":1785097411139,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":1785097411143,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":32,"time":1785097411143,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl new file mode 100644 index 0000000000..a4a1696bba --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -0,0 +1,30 @@ +{"type":"session","version":0,"id":"sdk-snapshot-subagent","createdAt":1785097408901,"cwd":"/tmp/sdk-snapshot-subagent-spawn-6fzuBd","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785097408905,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785097408905,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once with description 'echo probe' and prompt: Reply with exactly: child answer 42. Then reply with the subagent's final answer verbatim."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785097408907,"data":{"title":"Use the subagent tool exactly","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785097408908,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785097408908,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785097409495,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785097409496,"data":{"turn":1,"step":1,"index":0,"dt":[170,25,1,0,0,0,0,24,0,1,0,0,0,26,0,0,0,0,0,26,0,0,0,0,0,30,0,0,1,0,0,20,1,0,0,28,0,1,0,0,0,23,1,0,0,0,0,25,1,25,26,1,0,0],"texts":["The"," user"," wants"," me"," to",":\n","1","."," Use"," the"," sub","agent"," tool"," exactly"," once"," with"," description"," '","echo"," probe","'"," and"," prompt"," '","Reply"," with"," exactly",":"," child"," answer"," ","42",".'\n","2","."," Then"," reply"," with"," the"," sub","agent","'s"," final"," answer"," verb","atim",".\n\n","Let"," me"," do"," this"," step"," by"," step","."]}} +{"type":"assistant/chunk","seq":61,"time":1785097410031,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":62,"time0":1785097410031,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,26,0,0,0,51,1,0,0,0,0,26,1,0,0,0,25,1,0,0,25,1,0],"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","args":["","{","\"","description","\"",": ","\"","echo"," probe","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly",":"," child"," answer"," ","42",".","\"","}"]}} +{"type":"assistant/chunk","seq":90,"time":1785097410271,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once with description 'echo probe' and prompt 'Reply with exactly: child answer 42.'\n2. Then reply with the subagent's final answer verbatim.\n\nLet me do this step by step."}}}} +{"type":"assistant/chunk","seq":91,"time":1785097410272,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}}}} +{"type":"assistant/chunk","seq":92,"time":1785097410272,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}}}} +{"type":"assistant/chunk","seq":93,"time":1785097410272,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":94,"time":1785097410276,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to:\n1. Use the subagent tool exactly once with description 'echo probe' and prompt 'Reply with exactly: child answer 42.'\n2. Then reply with the subagent's final answer verbatim.\n\nLet me do this step by step."},{"type":"tool-call","id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} +{"type":"tool/call","seq":95,"time":1785097410277,"data":{"turn":1,"step":1,"callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","arguments":"{\"description\": \"echo probe\", \"prompt\": \"Reply with exactly: child answer 42.\"}"}} +{"type":"tool/result","seq":96,"time":1785097411146,"data":{"turn":1,"step":1,"callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","content":[{"type":"text","text":"child answer 42."}],"isError":false},"sourceEventSeqs":[95],"surfaceOp":"append"} +{"type":"step/end","seq":97,"time":1785097411148,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":98,"time":1785097411149,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":99,"time":1785097411681,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":100,"time0":1785097411681,"data":{"turn":1,"step":2,"index":0,"dt":[132,26,0,26,1,26,0,0,0,0,26,0,1,0,0,25,0,0,28,0,0,1,0,0,23],"texts":["The"," sub","agent"," replied"," with"," \"","child"," answer"," ","42",".\""," Now"," I"," need"," to"," reply"," with"," the"," sub","agent","'s"," final"," answer"," verb","atim","."]}} +{"type":"assistant/chunk","seq":126,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":127,"time0":1785097411997,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,26],"texts":["child"," answer"," ","42","."]}} +{"type":"assistant/chunk","seq":132,"time":1785097412024,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent replied with \"child answer 42.\" Now I need to reply with the subagent's final answer verbatim."}}}} +{"type":"assistant/chunk","seq":133,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} +{"type":"assistant/chunk","seq":134,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":135,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":136,"time":1785097412026,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent replied with \"child answer 42.\" Now I need to reply with the subagent's final answer verbatim."},{"type":"text","text":"child answer 42."}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}},"sourceEventSeqs":[99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135],"surfaceOp":"append"} +{"type":"step/end","seq":137,"time":1785097412028,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":138,"time":1785097412028,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl new file mode 100644 index 0000000000..d924d9c534 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl @@ -0,0 +1,38 @@ +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"}},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[1],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"SD"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"K"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" snapshot"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"SD"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"K"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" snapshot"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" OK"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."},{"type":"text","text":"SDK snapshot OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":36,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.finished","params":{"sessionId":"{{sessionId}}","status":"ok","reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/result.expected.json b/examples/jsonrpc-agent/tests/snapshots/text-turn/result.expected.json new file mode 100644 index 0000000000..46cd4334e0 --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/result.expected.json @@ -0,0 +1 @@ +{"status":"ok","reason":{"kind":"completed"},"finalResponse":"SDK snapshot OK"} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl new file mode 100644 index 0000000000..db86b76c0a --- /dev/null +++ b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":0,"id":"sdk-snapshot-text","createdAt":1785097381464,"cwd":"/tmp/sdk-snapshot-text-turn-OwFEJv","delegationDepth":0} +{"type":"turn/start","seq":0,"time":1785097381468,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1785097381469,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785097381471,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785097381472,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785097381472,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785097381978,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785097381979,"data":{"turn":1,"step":1,"index":0,"dt":[138,28,27,1,0,0,24,1,0,0,0,26,0,1,25,1,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]}} +{"type":"assistant/chunk","seq":25,"time":1785097382251,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":26,"time0":1785097382251,"data":{"turn":1,"step":1,"index":1,"dt":[27,0,1],"texts":["SD","K"," snapshot"," OK"]}} +{"type":"assistant/chunk","seq":30,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."}}}} +{"type":"assistant/chunk","seq":31,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}}} +{"type":"assistant/chunk","seq":32,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":33,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":34,"time":1785097382283,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"SDK snapshot OK\". Let me do that."},{"type":"text","text":"SDK snapshot OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} +{"type":"step/end","seq":35,"time":1785097382288,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":36,"time":1785097382288,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/package.json b/examples/package.json index 8a81d399b8..51fc48b8fa 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", @@ -30,24 +30,26 @@ "@deepseek-ai/dsh-llm-replay": "workspace:*", "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", - "@deepseek-ai/dsh-plan-mode": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", + "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", + "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", - "@deepseek-ai/dsh-tui-demo": "workspace:*", - "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", + "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", @@ -57,15 +59,17 @@ "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", - "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-lsp": "workspace:*", + "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", + "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-tui-demo": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-local": "workspace:*", diff --git a/examples/tui-agent/README.i18n.yaml b/examples/tui-agent/README.i18n.yaml index 631848bb77..3d29530411 100644 --- a/examples/tui-agent/README.i18n.yaml +++ b/examples/tui-agent/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: fdf3972f98f8ff9690b71469ae415dc18d339f10 -README.zh.md: 71f7ae949d034757a20adfae2cbe566011edc584 +# pnpm run verify-translation-pairing --write examples/tui-agent/README.md +README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 +README.zh.md: b3f6dc18536b159379eac7433367ccf2cd8fcc53 diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index fdf3972f98..ea8695d37e 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -19,7 +19,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. ### Resuming a prior session @@ -53,7 +53,7 @@ This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads | Entry | Demonstrates | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it depends on the Loader's internal module access | -| `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | +| `llm-deepseek` | the default native adapter | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | | `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the pi-tui channel + a pre-created `main` agent | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md index 71f7ae949d..b3f6dc1853 100644 --- a/examples/tui-agent/README.zh.md +++ b/examples/tui-agent/README.zh.md @@ -19,7 +19,7 @@ pnpm run demo:tui `todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 -TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 和 Enter,或使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 +TUI 渲染 Markdown 历史、推理、工具所有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 ### 恢复早先的会话 @@ -53,7 +53,7 @@ pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay | 配置项 | 演示内容 | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | -| `llm-deepseek` | 通过配置提供真实 `LlmAdapter`(`!!js process.env.…` 密钥);将一行替换为 `@deepseek-ai/dsh-llm-pi-ai` 即可使用库后端对照实现 | +| `llm-deepseek` | 默认原生适配器 | | `bash` (`dsh-bash-local`) | 执行器实现:bash seam 的可替换一半。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | | `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent | | `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent | diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index fd6d163952..c6fc223113 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -12,6 +12,8 @@ flowchart LR cfg --> plugin_tui_hmr plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_tui_subprocess plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] cfg --> plugin_tui_bash plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] @@ -69,6 +71,7 @@ flowchart LR | --- | --- | | `hmr` | `@cordisjs/plugin-hmr` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index e96ef680b2..7c8b03db05 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -21,6 +21,10 @@ reasoningEffort: max # Local executor for the app bundle's bash tool. +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' config: diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index c3bdbfd1b1..517fa6adab 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -1,6 +1,11 @@ import type { Context } from 'cordis' -import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelInfo, + LlmResolvedModelInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}` @@ -35,8 +40,28 @@ class ScriptedTuiAdapter extends LlmAdapter { ]) } - override resolveModelContext(_provider: string, _model: string): Promise { - return Promise.resolve({ contextWindow: 128_000 }) + override resolveModel( + provider: string, + model: string, + ): Promise { + return Promise.resolve({ + provider, + id: model, + name: model === 'tui-scripted-model-pro' ? 'Scripted Pro' : 'Scripted Base', + context: { contextWindow: 128_000 }, + ...model !== 'tui-scripted-model-pro' + ? {} + : { + reasoning: { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }, + }) } override async * stream(options: GenerateOptions): AsyncIterable { @@ -47,14 +72,27 @@ class ScriptedTuiAdapter extends LlmAdapter { for (const chunk of textChunks(TITLE_TEXT)) yield chunk return } - if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) { - throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables') + if ( + options.model !== 'tui-scripted-model-pro' + || !options.system?.includes('tui-scripted-model-pro') + || options.reasoningEffort !== ReasoningEffortId('max') + ) { + throw new Error('the scripted TUI request did not apply the selected model and reasoning effort') } const lastMessage = options.messages.at(-1) - const lastText = (lastMessage?.content ?? []) - .filter(block => block.type === 'text') - .map(block => block.text) - .join('\n') + // The loop appends plugin-sourced context (the plan-mode notice, the + // tool-skill catalog) AFTER the admitted prompt, so the scripted trigger + // may sit one or more user messages back: scan the whole trailing run of + // user-role messages since the last assistant message. + const trailingUserTexts: string[] = [] + for (let index = options.messages.length - 1; index >= 0; index--) { + const message = options.messages[index] + if (message?.role !== 'user') break + for (const block of message.content) { + if (block.type === 'text') trailingUserTexts.push(block.text) + } + } + const lastText = trailingUserTexts.join('\n') if (lastText.includes(DEFAULT_MODE_PROBE)) { if (options.system?.includes('Stay in plan mode for this scripted TUI test.')) { throw new Error('the scripted TUI request retained plan guidance after /plan off') diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index 307c5a1dfe..a32bdf987b 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -4,6 +4,10 @@ - id: scripted-llm name: './tui-scripted-llm.ts' +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index 700c67f660..ea088a20f9 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -1,7 +1,7 @@ -import { spawn } from 'node:child_process' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { execa } from 'execa' import { resolveExampleLaunch, type ExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const POSIX_PTY_DRIVER = String.raw` @@ -94,35 +94,32 @@ async function runPosixPtySmoke( options: TuiPtySmokeOptions, timeoutMs: number, ): Promise { - return await new Promise((resolve, reject) => { - const child = spawn('python3', [ - '-c', - POSIX_PTY_DRIVER, - launch.command, - JSON.stringify(launch.args), - JSON.stringify(launch.env), - cwd, - JSON.stringify(options.actions ?? []), - String(options.expectedExitCode ?? 0), - String(timeoutMs / 1_000), - ], { stdio: ['ignore', 'pipe', 'pipe'] }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { stdout += chunk }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`${options.label} PTY driver did not exit. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, timeoutMs + 5_000) - child.once('error', (error) => { clearTimeout(timer); reject(error) }) - child.once('exit', (code) => { - clearTimeout(timer) - if (code === 0) resolve(stdout) - else reject(new Error(`${options.label} PTY driver exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }) + // The driver owns the PTY deadline (`timeoutMs`); the outer execa deadline + // only backstops a wedged python3 process itself. + const result = await execa('python3', [ + '-c', + POSIX_PTY_DRIVER, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), + cwd, + JSON.stringify(options.actions ?? []), + String(options.expectedExitCode ?? 0), + String(timeoutMs / 1_000), + ], { + stdin: 'ignore', + timeout: timeoutMs + 5_000, + killSignal: 'SIGKILL', + reject: false, + stripFinalNewline: false, }) + if (result.timedOut) { + throw new Error(`${options.label} PTY driver did not exit. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + if (result.failed) { + throw new Error(`${options.label} PTY driver exited ${String(result.exitCode)}. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + } + return result.stdout } async function runWindowsPtySmoke( diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt index 9ffee44378..0d8f454204 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt @@ -1,63 +1,54 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the bash tool to — DSH TUI snapshot" -cursor hidden column=1 viewportRow=25 bufferRow=25 +cursor hidden column=7 viewportRow=24 bufferRow=24 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the bash tool to" style 1-20 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop." - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue -8| -9| " Reasoning " - style 1-9 fg=bright-black italic -10| " The user wants me to run a simple bash command and then reply with \"DONE\". " - style 1-74 fg=bright-black italic -11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ echo TERMINAL_OK " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-19 bold -14| "▌ Echo TERMINAL_OK to verify terminal access " - style 0-0 fg=green - style 2-43 fg=bright-black -15| "▌ TERMINAL_OK " - style 0-0 fg=green -16| "▌ [exit 0] " - style 0-0 fg=green - style 2-9 dim -17| "▌ " - style 0-0 fg=green -18| -19| " Reasoning " - style 1-9 fg=bright-black italic -20| " The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " - style 1-91 fg=bright-black italic -21| -22| " Assistant " - style 1-9 fg=bright-magenta bold -23| " DONE " -24| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -25| " " - style 1-1 inverse -26| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -27| "deepseek-v4-flash /workspace/project ↑3.0k ↓115 cache 48% 3% contex" - style 0-88 dim - style 91-99 dim -28-35| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop. " +6| +7| "Assistant " + style 0-8 fg=bright-magenta bold underline +8| "Reasoning " + style 0-8 fg=bright-black italic +9| "The user wants me to run a simple bash command and then reply with \"DONE\". " + style 0-73 fg=bright-black italic +10| +11| "● Tool / bash / Echo TERMINAL_OK to verify terminal access" + style 0-57 fg=green +12| "$ echo TERMINAL_OK " + style 0-17 fg=cyan +13| "TERMINAL_OK " +14| "[exit 0] " + style 0-7 dim +15| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +16| +17| "Assistant " + style 0-8 fg=bright-magenta bold underline +18| "Reasoning " + style 0-8 fg=bright-black italic +19| "The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " + style 0-90 fg=bright-black italic +20| "DONE " +21| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +22| +23| "/workspace/project deepseek-v4-flash ↑3.0k ↓115 cache 48% 3% contex" + style 0-46 fg=bright-blue bold + style 49-65 fg=bright-black + style 68-88 fg=bright-black + style 91-99 fg=bright-black +24| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +25-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt index aad4b2cd50..248ea2f4e7 100644 --- a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt @@ -1,65 +1,57 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=1 viewportRow=26 bufferRow=26 +cursor hidden column=7 viewportRow=26 bufferRow=26 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Using ONE run_code program: call" style 1-32 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " - style 0-0 fg=bright-blue - style 79-99 fg=cyan -7| "▌ '{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " - style 0-0 fg=bright-blue - style 2-74 fg=cyan -8| "▌ number of lines in its output. Reply with just that number and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to write a single run_code program that calls bash exactly once with a specific " - style 1-99 fg=bright-black italic -13| " command, then returns only the number of lines in its output. " - style 1-61 fg=bright-black italic -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Count lines in seq/awk output " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-32 bold -17| "▌ 200 " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| " Reasoning " - style 1-9 fg=bright-black italic -21| " The result is 200 lines. The user wants me to reply with just that number and stop. " - style 1-83 fg=bright-black italic -22| -23| " Assistant " - style 1-9 fg=bright-magenta bold -24| " 200 " -25| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -26| " " - style 1-1 inverse -27| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -28| "deepseek-v4-flash /workspace/project ↑123 ↓208 cache 99% 3% c" - style 0-93 dim - style 96-99 dim -29-35| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Using ONE run_code program: call the bash tool exactly once with the command seq 1 200 | awk " + style 77-99 fg=cyan +6| "'{printf \"line %04d: the quick brown fox jumps over the lazy dog\\n\", $1}', then return ONLY the " + style 0-72 fg=cyan +7| "number of lines in its output. Reply with just that number and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to write a single run_code program that calls bash exactly once with a specific " + style 0-99 fg=bright-black italic +12| "command, then returns only the number of lines in its output. " + style 0-60 fg=bright-black italic +13| +14| "● Tool / run_code" + style 0-16 fg=green +15| "Count lines in seq/awk output " +16| "200 " +17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +18| +19| "Assistant " + style 0-8 fg=bright-magenta bold underline +20| "Reasoning " + style 0-8 fg=bright-black italic +21| "The result is 200 lines. The user wants me to reply with just that number and stop. " + style 0-82 fg=bright-black italic +22| "200 " +23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +24| +25| "/workspace/project deepseek-v4-flash ↑123 ↓208 cache 99% 3% c" + style 0-52 fg=bright-blue bold + style 55-71 fg=bright-black + style 74-93 fg=bright-black + style 96-99 fg=bright-black +26| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +27-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt index 8dd0c37da1..45879f889f 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt @@ -1,136 +1,140 @@ -terminal 100x36 buffer=normal length=64 base=28 viewport=28 +terminal 100x36 buffer=normal length=62 base=26 viewport=26 lifecycle started=1 stopped=0 progress=inactive title "Using ONE run_code program: call — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=61 +cursor hidden column=7 viewportRow=35 bufferRow=61 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Using ONE run_code program: call" style 1-32 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " - style 0-0 fg=bright-blue - style 65-77 fg=cyan - style 92-99 fg=cyan -7| "▌ CODE_TWO. Inside that same program, console.log exactly captured output, then return the two " - style 0-0 fg=bright-blue - style 2-9 fg=cyan - style 58-72 fg=cyan -8| "▌ outputs joined with a plus sign. Reply with that joined string only and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to write a single run_code program that: " - style 1-36 fg=bright-black italic - style 37-44 fg=cyan - style 45-58 fg=bright-black italic -13| " 1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " - style 1-3 fg=bright-blue - style 4-9 fg=bright-black italic - style 10-13 fg=cyan - style 14-38 fg=bright-black italic - style 39-51 fg=cyan - style 52-63 fg=bright-black italic - style 64-76 fg=cyan -14| " 2. console.log exactly captured output " - style 1-3 fg=bright-blue - style 4-14 fg=cyan - style 15-23 fg=bright-black italic - style 24-38 fg=cyan -15| " 3. Returns the two outputs joined with a plus sign " - style 1-3 fg=bright-blue - style 4-50 fg=bright-black italic -16| " " -17| " Let me think about the structure. The bash tool returns an object with stdout/stderr. I need to " - style 1-38 fg=bright-black italic - style 39-42 fg=cyan - style 43-99 fg=bright-black italic -18| " extract the stdout text from each call. " - style 1-39 fg=bright-black italic -19| " " -20| " Looking at the bash output type: " - style 1-32 fg=bright-black italic +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " + style 63-75 fg=cyan + style 90-99 fg=cyan +6| "CODE_TWO. Inside that same program, console.log exactly captured output, then return the two outputs" + style 0-7 fg=cyan + style 56-70 fg=cyan +7| "joined with a plus sign. Reply with that joined string only and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to write a single run_code program that: " + style 0-35 fg=bright-black italic + style 36-43 fg=cyan + style 44-57 fg=bright-black italic +12| "1. Calls bash tool twice - first with echo CODE_ONE, then with echo CODE_TWO " + style 0-2 fg=bright-blue + style 3-8 fg=bright-black italic + style 9-12 fg=cyan + style 13-37 fg=bright-black italic + style 38-50 fg=cyan + style 51-62 fg=bright-black italic + style 63-75 fg=cyan +13| "2. console.log exactly captured output " + style 0-2 fg=bright-blue + style 3-13 fg=cyan + style 14-22 fg=bright-black italic + style 23-37 fg=cyan +14| "3. Returns the two outputs joined with a plus sign " + style 0-2 fg=bright-blue + style 3-49 fg=bright-black italic +15| " " +16| "Let me think about the structure. The bash tool returns an object with stdout/stderr. I need to " + style 0-37 fg=bright-black italic + style 38-41 fg=cyan + style 42-99 fg=bright-black italic +17| "extract the stdout text from each call. " + style 0-38 fg=bright-black italic +18| " " +19| "Looking at the bash output type: " + style 0-31 fg=bright-black italic +20| " " 21| " " -22| " ``` " - style 1-3 dim -23| " { " -24| " kind: \"foreground\"; " -25| " exitCode: number | null; " -26| " signal: string | null; " -27| " timedOut: boolean; " -28| " aborted: boolean; " -29| " timeoutMs: number; " -30| " stdout: { " -31| " text: string; " -32| " truncated: boolean; " -33| " spillPath?: string; " -34| " }; " -35| " stderr: { ... }; " -36| " sandbox?: { ... }; " -37| " } " -38| " ``` " - style 1-3 dim -39| " " -40| " So I need to access .stdout.text from each result. " - style 1-20 fg=bright-black italic - style 21-32 fg=cyan - style 33-50 fg=bright-black italic -41| " " -42| " Let me write the program. " - style 1-25 fg=bright-black italic -43| -44| "▌ " - style 0-0 fg=green -45| "▌ ✓ Call bash twice, log, join outputs " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-37 bold -46| "▌ captured output " - style 0-0 fg=green -47| "▌ CODE_ONE+CODE_TWO " - style 0-0 fg=green -48| "▌ " - style 0-0 fg=green -49| -50| " Reasoning " - style 1-9 fg=bright-black italic -51| " The program ran successfully. The output shows: " - style 1-47 fg=bright-black italic -52| " - captured output (from console.log) " - style 1-2 fg=bright-blue - style 3-17 fg=cyan - style 18-36 fg=bright-black italic -53| " - CODE_ONE+CODE_TWO (the returned joined string) " - style 1-2 fg=bright-blue - style 3-19 fg=cyan - style 20-48 fg=bright-black italic +22| " { " + style 2-2 fg=cyan +23| " kind: \"foreground\"; " + style 2-22 fg=cyan +24| " exitCode: number | null; " + style 2-27 fg=cyan +25| " signal: string | null; " + style 2-25 fg=cyan +26| " timedOut: boolean; " + style 2-21 fg=cyan +27| " aborted: boolean; " + style 2-20 fg=cyan +28| " timeoutMs: number; " + style 2-21 fg=cyan +29| " stdout: { " + style 2-12 fg=cyan +30| " text: string; " + style 2-18 fg=cyan +31| " truncated: boolean; " + style 2-24 fg=cyan +32| " spillPath?: string; " + style 2-24 fg=cyan +33| " }; " + style 2-5 fg=cyan +34| " stderr: { ... }; " + style 2-19 fg=cyan +35| " sandbox?: { ... }; " + style 2-21 fg=cyan +36| " } " + style 2-2 fg=cyan +37| " " +38| " " +39| "So I need to access .stdout.text from each result. " + style 0-19 fg=bright-black italic + style 20-31 fg=cyan + style 32-49 fg=bright-black italic +40| " " +41| "Let me write the program. " + style 0-24 fg=bright-black italic +42| +43| "● Tool / run_code" + style 0-16 fg=green +44| "Call bash twice, log, join outputs " +45| "captured output " +46| "CODE_ONE+CODE_TWO " +47| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +48| +49| "Assistant " + style 0-8 fg=bright-magenta bold underline +50| "Reasoning " + style 0-8 fg=bright-black italic +51| "The program ran successfully. The output shows: " + style 0-46 fg=bright-black italic +52| "- captured output (from console.log) " + style 0-1 fg=bright-blue + style 2-16 fg=cyan + style 17-35 fg=bright-black italic +53| "- CODE_ONE+CODE_TWO (the returned joined string) " + style 0-1 fg=bright-blue + style 2-18 fg=cyan + style 19-47 fg=bright-black italic 54| " " -55| " The user asked me to reply with that joined string only and stop. So I'll reply with just " - style 1-99 fg=bright-black italic -56| " CODE_ONE+CODE_TWO. " - style 1-17 fg=cyan - style 18-18 fg=bright-black italic -57| -58| " Assistant " - style 1-9 fg=bright-magenta bold -59| " CODE_ONE+CODE_TWO " -60| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -61| " " - style 1-1 inverse -62| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -63| "deepseek-v4-flash /workspace/project ↑182 ↓446 cache 98% 4% context tools:c" - style 0-78 dim - style 81-99 dim +55| "The user asked me to reply with that joined string only and stop. So I'll reply with just " + style 0-99 fg=bright-black italic +56| "CODE_ONE+CODE_TWO. " + style 0-16 fg=cyan + style 17-17 fg=bright-black italic +57| "CODE_ONE+CODE_TWO " +58| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +59| +60| "/workspace/project deepseek-v4-flash ↑182 ↓446 cache 98% 4% context" + style 0-37 fg=bright-blue bold + style 40-56 fg=bright-black + style 59-78 fg=bright-black + style 81-90 fg=bright-black +61| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl index 9a80b08e9e..b9ce1d12da 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl @@ -1,6 +1,6 @@ {"type": "session", "version": 0, "id": "11111111-1111-4111-8111-111111111111", "createdAt": 1783950000000, "cwd": "/tmp/advanced-acp", "delegationDepth": 0} {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -10,17 +10,17 @@ {"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} {"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} {"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} {"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}}} -{"type":"assistant/chunk","seq":16,"time":1783950000017,"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: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}}}} +{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}}} +{"type":"assistant/chunk","seq":16,"time":1783950000017,"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\": \"Verify the temporary marker Plugin\"}"}}}} {"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} -{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'dynamic' })\", \"description\": \"Verify the dynamically mounted marker service\"}"}} -{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} +{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Verify the temporary marker Plugin\"}"}} +{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} {"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} @@ -51,7 +51,7 @@ {"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} {"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} {"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} {"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt index a3739deefa..b4add028d8 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt @@ -1,106 +1,95 @@ -terminal 100x36 buffer=normal length=48 base=12 viewport=12 +terminal 100x36 buffer=normal length=59 base=23 viewport=23 lifecycle started=1 stopped=0 progress=inactive title "Run this advanced flow exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=45 +cursor hidden column=7 viewportRow=35 bufferRow=58 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Run this advanced flow exactly" style 1-30 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use " - style 0-0 fg=bright-blue -7| "▌ run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a " - style 0-0 fg=bright-blue -8| "▌ direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then " - style 0-0 fg=bright-blue -9| "▌ reply with exactly ADVANCED_ACP_OK. " - style 0-0 fg=bright-blue -10| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use " +6| "run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a " +7| "direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply " +8| "with exactly ADVANCED_ACP_OK. " +9| +10| "Assistant " + style 0-8 fg=bright-magenta bold underline 11| -12| "▌ " - style 0-0 fg=green -13| "▌ ✓ Mount plugin into live cordis runtime " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-40 bold -14| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " - style 0-0 fg=green -15| "▌ " - style 0-0 fg=green -16| -17| "▌ " - style 0-0 fg=green -18| "▌ ✓ Verify the dynamically mounted marker service " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-48 bold -19| "▌ ## dynamic " - style 0-0 fg=green -20| "▌ - dyn-1: snapshot-marker [active] " - style 0-0 fg=green -21| "▌ " - style 0-0 fg=green -22| -23| "▌ " - style 0-0 fg=green -24| "▌ ✓ subagent " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-11 bold -25| "▌ DIRECT_CHILD_OK " - style 0-0 fg=green -26| "▌ " - style 0-0 fg=green -27| -28| "▌ " - style 0-0 fg=green -29| "▌ ✓ workflow: advanced-acp-snapshot " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-34 bold -30| "▌ workflow \"advanced-acp-snapshot\" completed (1 agent). " - style 0-0 fg=green -31| "▌ Return value: " - style 0-0 fg=green -32| "▌ { " - style 0-0 fg=green -33| "▌ \"reply\": \"WORKFLOW_CHILD_OK\" " - style 0-0 fg=green -34| "▌ } " - style 0-0 fg=green -35| "▌ " - style 0-0 fg=green +12| "● Tool / cordis_mount" + style 0-20 fg=green +13| "Mount temporary Cordis Plugin " +14| "Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH " +15| "restarts). " +16| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +17| +18| "Assistant " + style 0-8 fg=bright-magenta bold underline +19| +20| "● Tool / run_code" + style 0-16 fg=green +21| "Verify the temporary marker Plugin " +22| " " +23| "Temporary Plugins " + style 0-16 fg=bright-blue bold +24| " " +25| "- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: " + style 0-1 fg=bright-blue +26| " until unmounted or DSH restarts " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "Assistant " + style 0-8 fg=bright-magenta bold underline +30| +31| "● Tool / subagent" + style 0-16 fg=green +32| "DIRECT_CHILD_OK " +33| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +34| +35| "Assistant " + style 0-8 fg=bright-magenta bold underline 36| -37| "▌ " - style 0-0 fg=green -38| "▌ ✓ Unmount dyn-1 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -39| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " - style 0-0 fg=green -40| "▌ " - style 0-0 fg=green -41| -42| " Assistant " - style 1-9 fg=bright-magenta bold -43| " ADVANCED_ACP_OK " -44| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -45| " " - style 1-1 inverse -46| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -47| "deepseek-v4-flash /workspace/project ↑18 ↓18 cache 0% 8% cont" - style 0-90 dim - style 93-99 dim +37| "● Tool / workflow" + style 0-16 fg=green +38| "workflow: advanced-acp-snapshot " +39| "workflow \"advanced-acp-snapshot\" completed (1 agent). " +40| "Return value: " +41| "{ " +42| " \"reply\": \"WORKFLOW_CHILD_OK\" " +43| "} " +44| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +45| +46| "Assistant " + style 0-8 fg=bright-magenta bold underline +47| +48| "● Tool / cordis_unmount" + style 0-22 fg=green +49| "Unmount temporary Cordis Plugin dyn-1 " +50| "Temporary Plugin dyn-1 was unmounted and removed. " +51| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +52| +53| "Assistant " + style 0-8 fg=bright-magenta bold underline +54| "ADVANCED_ACP_OK " +55| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +56| +57| "/workspace/project deepseek-v4-flash ↑18 ↓18 cache 0% 8% cont" + style 0-52 fg=bright-blue bold + style 55-71 fg=bright-black + style 74-90 fg=bright-black + style 93-99 fg=bright-black +58| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt index 48adbbb11a..c704cb2399 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt @@ -1,96 +1,80 @@ -terminal 100x36 buffer=normal length=45 base=9 viewport=9 +terminal 100x36 buffer=normal length=43 base=7 viewport=7 lifecycle started=1 stopped=0 progress=inactive title "Use the workflow tool exactly — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=42 +cursor hidden column=7 viewportRow=35 bufferRow=42 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the workflow tool exactly" style 1-29 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " - style 0-0 fg=bright-blue -7| "▌ \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " - style 0-0 fg=bright-blue -8| "▌ phase('Run') " - style 0-0 fg=bright-blue -9| "▌ const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " - style 0-0 fg=bright-blue -10| "▌ return { reply } " - style 0-0 fg=bright-blue -11| "▌ After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any " - style 0-0 fg=bright-blue -12| "▌ other tool. " - style 0-0 fg=bright-blue -13| "▌ " - style 0-0 fg=bright-blue -14| -15| " Reasoning " - style 1-9 fg=bright-black italic -16| " The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " - style 1-99 fg=bright-black italic -17| " follow the instructions: " - style 1-24 fg=bright-black italic -18| " " -19| " 1. args omitted (so I don't include it) " - style 1-3 fg=bright-blue - style 4-39 fg=bright-black italic -20| " 2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " - style 1-3 fg=bright-blue - style 4-82 fg=bright-black italic -21| " 3. script = as given verbatim " - style 1-3 fg=bright-blue - style 4-29 fg=bright-black italic -22| " 4. After it returns, reply with \"WORKFLOW_DONE\" " - style 1-3 fg=bright-blue - style 4-47 fg=bright-black italic -23| " " -24| " Let me do exactly that. " - style 1-23 fg=bright-black italic -25| -26| "▌ " - style 0-0 fg=green -27| "▌ ✓ workflow: snapshot-flow " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-26 bold -28| "▌ workflow \"snapshot-flow\" completed (1 agent). " - style 0-0 fg=green -29| "▌ Return value: " - style 0-0 fg=green -30| "▌ { " - style 0-0 fg=green -31| "▌ \"reply\": \"WF_CHILD_OK\" " - style 0-0 fg=green -32| "▌ } " - style 0-0 fg=green -33| "▌ " - style 0-0 fg=green -34| -35| " Reasoning " - style 1-9 fg=bright-black italic -36| " The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " - style 1-99 fg=bright-black italic -37| " \"WORKFLOW_DONE\" and stop. " - style 1-25 fg=bright-black italic -38| -39| " Assistant " - style 1-9 fg=bright-magenta bold -40| " WORKFLOW_DONE " -41| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -42| " " - style 1-1 inverse -43| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -44| "deepseek-v4-flash /workspace/project ↑3.5k ↓227 cache 47% 3% context " - style 0-86 dim - style 89-99 dim +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " +6| "\"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " +7| "phase('Run') " +8| "const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " +9| "return { reply } " +10| "After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other " +11| "tool. " +12| +13| "Assistant " + style 0-8 fg=bright-magenta bold underline +14| "Reasoning " + style 0-8 fg=bright-black italic +15| "The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " + style 0-99 fg=bright-black italic +16| "follow the instructions: " + style 0-23 fg=bright-black italic +17| " " +18| "1. args omitted (so I don't include it) " + style 0-2 fg=bright-blue + style 3-38 fg=bright-black italic +19| "2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " + style 0-2 fg=bright-blue + style 3-81 fg=bright-black italic +20| "3. script = as given verbatim " + style 0-2 fg=bright-blue + style 3-28 fg=bright-black italic +21| "4. After it returns, reply with \"WORKFLOW_DONE\" " + style 0-2 fg=bright-blue + style 3-46 fg=bright-black italic +22| " " +23| "Let me do exactly that. " + style 0-22 fg=bright-black italic +24| +25| "● Tool / workflow" + style 0-16 fg=green +26| "workflow: snapshot-flow " +27| "workflow \"snapshot-flow\" completed (1 agent). " +28| "Return value: " +29| "{ " +30| " \"reply\": \"WF_CHILD_OK\" " +31| "} " +32| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +33| +34| "Assistant " + style 0-8 fg=bright-magenta bold underline +35| "Reasoning " + style 0-8 fg=bright-black italic +36| "The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " + style 0-99 fg=bright-black italic +37| "\"WORKFLOW_DONE\" and stop. " + style 0-24 fg=bright-black italic +38| "WORKFLOW_DONE " +39| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +40| +41| "/workspace/project deepseek-v4-flash ↑3.5k ↓227 cache 47% 3% context" + style 0-44 fg=bright-blue bold + style 47-63 fg=bright-black + style 66-86 fg=bright-black + style 89-98 fg=bright-black +42| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt index 099bf63b81..e4fa63468a 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt @@ -1,70 +1,62 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Reply with exactly the word: — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=33 +cursor hidden column=7 viewportRow=30 bufferRow=30 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Reply with exactly the word:" style 1-28 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| " Entering plan mode (applies from the next step). Use /plan off to leave. " - style 1-72 fg=bright-black -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -8| "▌ Reply with exactly the word: ONE. No tools. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to reply with exactly the word \"ONE\" and use no tools. " - style 1-72 fg=bright-black italic -13| -14| " Assistant " - style 1-9 fg=bright-magenta bold -15| " ONE " +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Reply with exactly the word: ONE. No tools. " +6| +7| "Entering plan mode (applies from the next step). Use /plan off to leave. " + style 0-71 fg=bright-black +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to reply with exactly the word \"ONE\" and use no tools. " + style 0-71 fg=bright-black italic +12| "ONE " +13| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +14| +15| "Leaving plan mode (applies from the next step). " + style 0-46 fg=bright-black 16| -17| " Leaving plan mode (applies from the next step). " - style 1-47 fg=bright-black -18| -19| " Context · plan-mode " - style 1-19 dim -20| " The user switched this session back to the default mode. " - style 1-56 fg=bright-black -21| -22| "▌ " - style 0-0 fg=bright-blue -23| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -24| "▌ Reply with exactly the word: TWO. No tools. " - style 0-0 fg=bright-blue -25| "▌ " - style 0-0 fg=bright-blue -26| -27| " Reasoning " - style 1-9 fg=bright-black italic -28| " The user wants me to reply with exactly the word \"TWO\" and no tools. " - style 1-68 fg=bright-black italic -29| -30| " Assistant " - style 1-9 fg=bright-magenta bold -31| " TWO " -32| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -33| " " - style 1-1 inverse -34| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -35| "deepseek-v4-flash /workspace/project ↑2.9k ↓41 cache 49% 3% co" - style 0-92 dim - style 95-99 dim +17| "You " + style 0-2 fg=bright-blue bold underline +18| "Reply with exactly the word: TWO. No tools. " +19| +20| "Context · plan-mode " + style 0-18 dim +21| "The user switched this session back to the default mode. " + style 0-55 fg=bright-black +22| +23| "Assistant " + style 0-8 fg=bright-magenta bold underline +24| "Reasoning " + style 0-8 fg=bright-black italic +25| "The user wants me to reply with exactly the word \"TWO\" and no tools. " + style 0-67 fg=bright-black italic +26| "TWO " +27| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +28| +29| "/workspace/project deepseek-v4-flash ↑2.9k ↓41 cache 49% 3% co" + style 0-51 fg=bright-blue bold + style 54-70 fg=bright-black + style 73-92 fg=bright-black + style 95-99 fg=bright-black +30| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt index 70fa5b173a..3d99c45758 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt @@ -1,81 +1,52 @@ -terminal 100x36 buffer=normal length=37 base=1 viewport=1 +terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the read tool twice — DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=34 +cursor hidden column=7 viewportRow=27 bufferRow=27 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the read tool twice" style 1-23 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " - style 0-0 fg=bright-blue -7| "▌ " - style 0-0 fg=bright-blue +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " +6| +7| "Assistant " + style 0-8 fg=bright-magenta bold underline 8| -9| "▌ " - style 0-0 fg=green -10| "▌ ✓ Read a.txt " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-13 bold -11| "▌ /workspace/project/a.txt " - style 0-0 fg=green -12| "▌ file " - style 0-0 fg=green -13| "▌ " - style 0-0 fg=green -14| "▌ 1: alpha " - style 0-0 fg=green -15| "▌ " - style 0-0 fg=green -16| "▌ (End of file - total 1 lines) " - style 0-0 fg=green -17| "▌ " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| "▌ " - style 0-0 fg=green -21| "▌ ✓ Read b.txt " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-13 bold -22| "▌ /workspace/project/b.txt " - style 0-0 fg=green -23| "▌ file " - style 0-0 fg=green -24| "▌ " - style 0-0 fg=green -25| "▌ 1: beta " - style 0-0 fg=green -26| "▌ " - style 0-0 fg=green -27| "▌ (End of file - total 1 lines) " - style 0-0 fg=green -28| "▌ " - style 0-0 fg=green -29| "▌ " - style 0-0 fg=green -30| -31| " Assistant " - style 1-9 fg=bright-magenta bold -32| " DONE " -33| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -34| " " - style 1-1 inverse -35| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -36| "deepseek-v4-flash /workspace/project ↑20 ↓6 cache 0% 3% context t" - style 0-84 dim - style 87-99 dim +9| "● Tool / read" + style 0-12 fg=green +10| "Read a.txt " +11| "1: alpha " +12| " " +13| "(End of file - total 1 lines) " +14| +15| "● Tool / read" + style 0-12 fg=green +16| "Read b.txt " +17| "1: beta " +18| " " +19| "(End of file - total 1 lines) " +20| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +21| +22| "Assistant " + style 0-8 fg=bright-magenta bold underline +23| "DONE " +24| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +25| +26| "/workspace/project deepseek-v4-flash ↑20 ↓6 cache 0% 3% context" + style 0-47 fg=bright-blue bold + style 50-66 fg=bright-black + style 69-84 fg=bright-black + style 87-96 fg=bright-black +27| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +28-35| diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt index ec38d43c99..9dafcf576d 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt @@ -1,57 +1,48 @@ terminal 100x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "Use the todo_write tool to — DSH TUI snapshot" -cursor hidden column=1 viewportRow=31 bufferRow=31 +cursor hidden column=7 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold style 10-16 bold 1| " Use the todo_write tool to" style 1-26 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim +2| " main-session" + style 1-12 dim 3| -4| "▌ " - style 0-0 fg=bright-blue -5| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -6| "▌ Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " - style 0-0 fg=bright-blue -7| "▌ \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " - style 0-0 fg=bright-blue -8| "▌ reply with the single word DONE and stop. " - style 0-0 fg=bright-blue -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " The user wants me to use the todo_write tool to record a plan with exactly three todos in the " - style 1-99 fg=bright-black italic -13| " specified statuses, then reply with \"DONE\". " - style 1-43 fg=bright-black italic -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Update todo list " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-19 bold -17| "▌ Updated todo list: 2 pending, 1 in progress, 0 completed. " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| " Reasoning " - style 1-9 fg=bright-black italic -21| " The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " - style 1-95 fg=bright-black italic -22| -23| " Assistant " - style 1-9 fg=bright-magenta bold -24| " DONE " -25| +4| "You " + style 0-2 fg=bright-blue bold underline +5| "Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " +6| "\"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " +7| "reply with the single word DONE and stop. " +8| +9| "Assistant " + style 0-8 fg=bright-magenta bold underline +10| "Reasoning " + style 0-8 fg=bright-black italic +11| "The user wants me to use the todo_write tool to record a plan with exactly three todos in the " + style 0-99 fg=bright-black italic +12| "specified statuses, then reply with \"DONE\". " + style 0-42 fg=bright-black italic +13| +14| "● Tool / todo_write" + style 0-18 fg=green +15| "Update todo list " +16| "Updated todo list: 2 pending, 1 in progress, 0 completed. " +17| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +18| +19| "Assistant " + style 0-8 fg=bright-magenta bold underline +20| "Reasoning " + style 0-8 fg=bright-black italic +21| "The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " + style 0-94 fg=bright-black italic +22| "DONE " +23| "Model wait 0.0s · Completed 2026-07-21 12:00:00 " + style 0-46 dim +24-25| 26| "Plan" style 0-3 fg=bright-blue bold 27| " ● read the code" @@ -60,13 +51,13 @@ buffer style 2-2 dim 29| " ○ run the tests" style 2-2 dim -30| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -31| " " - style 1-1 inverse -32| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -33| "deepseek-v4-flash /workspace/project ↑3.1k ↓145 cache 47% 3% context tools:" - style 0-79 dim - style 82-99 dim -34-35| +30| "/workspace/project deepseek-v4-flash ↑3.1k ↓145 cache 47% 3% context" + style 0-37 fg=bright-blue bold + style 40-56 fg=bright-black + style 59-79 fg=bright-black + style 82-91 fg=bright-black +31| " dsh ◍ " + style 1-3 fg=bright-blue bold + style 5-6 fg=bright-black + style 7-7 inverse +32-35| diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 5967366c12..cdfd7d704d 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -71,17 +71,37 @@ async function seedResumeSession(cwd: string): Promise { ].join('\n')) } -/** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */ -async function readLoggedSystemPrompt(cwd: string): Promise { +/** Model-visible startup context from the first request in the workspace's persisted session log. */ +interface LoggedRequestContext { + /** The system prompt string the launcher sends. */ + system: string + /** The durable skill-catalog message serialized to text. */ + skillCatalog: string +} + +async function readLoggedRequestContext(cwd: string): Promise { const sessionsDir = join(cwd, '.sessions') const entries = await readdir(sessionsDir, { recursive: true }) // A single keyless run writes one session log; the source section is global, so any log carries it. const logRelPath = entries.find(name => name.endsWith('.jsonl')) if (logRelPath === undefined) throw new Error(`no session log written under ${sessionsDir}`) const lines = (await readFile(join(sessionsDir, logRelPath), 'utf8')).split('\n').filter(Boolean) + let skillCatalog = '' for (const line of lines) { - const event = JSON.parse(line) as { type: string; data: { header?: { system?: string } } } - if (event.type === 'request/header') return event.data.header?.system ?? '' + const event = JSON.parse(line) as SessionEvent + if ( + event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'dsh-tool-skill' + ) { + skillCatalog = JSON.stringify(event.data.content) + } + if (event.type === 'request/header') { + return { + system: event.data.header.system ?? '', + skillCatalog, + } + } } throw new Error(`session log ${logRelPath} has no request/header event`) } @@ -103,7 +123,7 @@ function smoke(overrides: Partial & { label: string }): Prom // other route (see fixtures/tui-scripted-llm.ts). const SELECT_PRO_MODEL = [ { waitFor: 'scripted TUI ready.', send: '/model\r' }, - { waitFor: 'Select model', send: '\x1b[B\r' }, + { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { @@ -156,6 +176,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { ], }) expect(output).toContain('I need one decision before I continue.') + expect(output).toContain('Reasoning effort: Max.') expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.') expect(output).toContain('Leaving plan mode (applies from the next step).') expect(output).toContain('Default mode confirmed.') @@ -175,6 +196,10 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('KV cache') expect(output).toContain('Context') expect(output).toContain('128,000') + expect(output).toContain('System prompt') + expect(output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.') + expect(output).toContain('Registered tools') + expect(output).toContain('ask_user_question') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -207,6 +232,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { { waitFor: 'Scripted skill body received.', send: '/exit\r' }, ], }) + expect(output).not.toContain('[instructions]') expect(output).toContain('Scripted skill body received.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) @@ -341,11 +367,13 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('tells the model where its own source lives, in the system prompt it sends', async () => { + it('tells the model its source path and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; // this test file sits an equal depth under the same root, so the same hop applies. + // The source-path line is a system-prompt section; the bundled skills reach the + // model through a durable user message, so each assertion targets its own field. const sourceRoot = fileURLToPath(new URL('../../..', import.meta.url)) - let loggedSystem = '' + let context: LoggedRequestContext = { system: '', skillCatalog: '' } await smoke({ label: 'dsh source-path prompt', tempDirPrefix: 'dsh-source-path-', @@ -357,8 +385,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { { waitFor: 'How should the scripted run proceed?', send: '\r' }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], - inspect: async (cwd) => { loggedSystem = await readLoggedSystemPrompt(cwd) }, + inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, }) - expect(loggedSystem).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(context.system).toContain(`Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`) + expect(context.skillCatalog).toContain("- `dsh-customize`: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly.") + expect(context.skillCatalog).toContain('- `dsh-upgrade`: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH.') + expect(context.skillCatalog).toContain('- `dsh-upstream-customization`: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing.') }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 1f2989f183..45e0e5538d 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -2,12 +2,13 @@ import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/pr import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' import CommandService from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -25,7 +26,7 @@ import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' -import { createTuiChat, FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-tui' +import { createTuiChat, FILE_REFERENCE_PROMPT, TuiPromptService } from '@deepseek-ai/dsh-tui' import LocalSpillStore from '@deepseek-ai/dsh-spill-local' import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -220,11 +221,13 @@ async function mountScenarioContext( skills: { local: { agentsHome: join(cwd, '.agents') } }, }) await ctx.plugin(TokenMeterService) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) await ctx.plugin(SnapshotLocalFileSystem, { cwd: '/' }) await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs) await ctx.plugin(UserInteractionService) + await ctx.plugin(TuiPromptService) // todo_write is opt-in: only the todo-plan scenario mounts it, matching the shipped // config that omits it. The other scenarios prove the default todo-free composition. if (scenario.enableTodo === true) await ctx.plugin(ToolTodo) @@ -262,6 +265,7 @@ interface ScenarioResult { } async function runScenario(scenario: Scenario): Promise { + const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 0, 0).getTime()) const dir = scenarioDir(scenario) const fixtureFile = join(dir, 'session.jsonl') const childFiles = childFixturePaths(scenario) @@ -294,7 +298,7 @@ async function runScenario(scenario: Scenario): Promise { const agent: Agent = handle.agent controller = createTuiChat(ctx, { sessionId: 'main-session', - color: true, + theme: { color: true }, showReasoning: true, title: 'DSH TUI snapshot', welcome: `Recorded replay: ${scenario.name}`, @@ -404,6 +408,7 @@ async function runScenario(scenario: Scenario): Promise { await ctx?.fiber.dispose() await terminal.dispose() await rm(cwd, { recursive: true, force: true }) + clock.mockRestore() } } diff --git a/examples/web-cordis/.gitignore b/examples/web-cordis/.gitignore new file mode 100644 index 0000000000..4da346bc81 --- /dev/null +++ b/examples/web-cordis/.gitignore @@ -0,0 +1,2 @@ +.sessions/ +.storages/ diff --git a/examples/web-cordis/cordis.yml b/examples/web-cordis/cordis.yml new file mode 100644 index 0000000000..80b598c696 --- /dev/null +++ b/examples/web-cordis/cordis.yml @@ -0,0 +1,18 @@ +# Opt-in Web composition for inspecting the self-referential Cordis tools. +# Temporary Plugin code can reach every injected live capability; treat this +# deployment like shell access, not as a security boundary. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../../apps/cli/cordis.yml + patches: + # AppCLIEntry normally injects this assembly-owned path before `dsh web` + # boots; the standalone Cordis launcher needs the equivalent patch here. + - id: webserver + config: + host: 127.0.0.1 + port: 3081 + distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname" + - insert: + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/knip.json b/knip.json index d4f3dffd6b..2395b0090e 100644 --- a/knip.json +++ b/knip.json @@ -6,13 +6,15 @@ "ignoreBinaries": [ "bwrap", "python3", - "sandbox-exec" + "sandbox-exec", + "taskkill" ], "ignoreWorkspaces": [ "vendor/*", "python/sdk-runtime" ], "ignoreDependencies": [ + "@yarnpkg/cli-dist", "lightningcss" ], "workspaces": { @@ -32,10 +34,15 @@ "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", + "headless-agent/tests/fixtures/telemetry-otel-driver.ts", + "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts", + "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" ], @@ -180,6 +187,16 @@ "tests/**/*.ts" ] }, + "packages/telemetry/session-telemetry-otel": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/util/brand": { "project": [ "src/**/*.ts" @@ -267,8 +284,14 @@ ] }, "packages/session-query/session-query-sqlite": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/code-runtime/code-runtime-worker": { "entry": [ @@ -321,8 +344,14 @@ ] }, "packages/session-persistence/session-checkpoint-policy": { - "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], - "project": ["src/**/*.ts", "tests/**/*.ts"] + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] }, "packages/util/paths": { "entry": [ @@ -493,15 +522,6 @@ "tests/**/*.ts" ] }, - "packages/subagent/subagent-subprocess": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/fs/tool-fs": { "entry": [ "tests/**/*.spec.ts", @@ -596,6 +616,16 @@ "src/**/*.ts", "tests/**/*.ts" ] + }, + "packages/subagent/subagent-dsh-sdk": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] } } } diff --git a/lefthook.yml b/lefthook.yml index cf2e6bb11d..ab7986ee2c 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,11 +1,11 @@ # Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full # repository-wide gate matrix. -# Install: `pnpm exec lefthook install` (runs automatically via postinstall). +# Install: `node scripts/install-lefthook.mjs` (runs automatically via postinstall). pre-commit: jobs: - name: lint (staged) - glob: '*.{ts,mts,cts,mjs}' + glob: '*.{ts,tsx,mts,cts,mjs}' exclude: - 'vendor/*/src/**' run: node_modules/.bin/eslint --fix {staged_files} diff --git a/package.json b/package.json index 90194a57d3..ee9e7fa912 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "build": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", "clean": "tsx scripts/clean.ts", + "change-scope": "tsx scripts/change-scope.ts", "typecheck": "tsc -b", "lint": "eslint .", "lint:fix": "eslint . --fix", @@ -40,6 +41,7 @@ "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", + "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "knip": "knip --treat-config-hints-as-errors", "publint": "tsx scripts/publint-all.ts", @@ -96,7 +98,7 @@ "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --import tsx apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node --import tsx apps/cli/src/bin.ts --config examples/cordis-agent/cordis.yml", + "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", @@ -105,6 +107,7 @@ }, "devDependencies": { "@agentclientprotocol/sdk": "0.25.1", + "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@stylistic/eslint-plugin": "^5.10.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", @@ -113,8 +116,10 @@ "@types/mdast": "^4.0.4", "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.1.8", + "@yarnpkg/cli-dist": "4.17.1", "eslint": "^10.4.1", "eslint-plugin-sonarjs": "^4.1.0", + "execa": "^10.0.0", "fast-check": "^4.8.0", "js-yaml": "^4.2.0", "jscpd": "^5.0.12", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 92433eacf4..38f61cfa7e 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: bf1f9afec45a8bc2b965aa90b2e59f341f2c3f86 -README.zh.md: 6adba69ffa254919165ed7f33825471cc7253208 +README.md: f95a774ca74dda332fd312044fad3f4fb7695dd2 +README.zh.md: 64bc5051a0938eddd6ca8523a6d19a5374737606 diff --git a/packages/README.md b/packages/README.md index bf1f9afec4..f95a774ca7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -13,6 +13,7 @@ Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass | [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface | | [`goal/`](goal/README.md) | Persisted same-session goal state and lifecycle | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: spawn seam + local process-tree implementation | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface | | [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface | | [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the runtime seam for model-written programs + a worker-thread backend | Product — stable surface | @@ -37,6 +38,7 @@ Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface | +| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface | | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 6adba69ffa..64bc5051a0 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -13,6 +13,7 @@ | [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定表面 | | [`goal/`](goal/README.md) | 持久化的同会话 goal 状态与生命周期 | 产品:稳定表面 | | [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定表面 | +| [`subprocess/`](subprocess/README.md) | 进程管理能力系列:spawn seam + 本地进程树实现 | 产品:稳定表面 | | [`bash/`](bash/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定表面 | | [`pty/`](pty/README.md) | 持久 PTY 能力系列:按所有者隔离的会话、本地实现和面向模型的工具 | 产品:稳定表面 | | [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:面向模型所写程序的运行时 seam + worker 线程后端 | 产品:稳定表面 | @@ -37,6 +38,7 @@ | [`session-persistence/`](session-persistence/README.md) | 持久化能力系列:seam + JSONL/SQLite 后端 | 产品:稳定表面 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定表面 | | [`session-title/`](session-title/README.md) | 日志支撑的会话标题:回退服务、共享 LLM 策略和选用提供方 | 产品:稳定表面 | +| [`telemetry/`](telemetry/README.md) | 会话上报:捕获/脱敏 seam、OTel 后端 | 产品:稳定表面 | | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定表面 | | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定表面 | | [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 | diff --git a/packages/acp/acp/src/codec.ts b/packages/acp/acp/src/codec.ts index 2a88af1184..8d4693d9d5 100644 --- a/packages/acp/acp/src/codec.ts +++ b/packages/acp/acp/src/codec.ts @@ -19,7 +19,6 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { return 'max_tokens' case 'aborted': case 'disposed': - case 'rejected': case 'interrupted': return 'cancelled' case 'error': diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 68e57dc020..883c2723f2 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -77,6 +77,12 @@ interface SessionRecord { resolve: (reason: StopReason) => void reject: (error: Error) => void turn: number | undefined + /** + * A failed turn's terminal reason, held until quiescence: a retry action + * closes the failed turn and opens a successor that adopts the prompt, so + * rejecting at `turn/end` would race the recovery. + */ + pendingError: Extract | undefined } | undefined } @@ -125,15 +131,11 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight.resolve(reason) } - const settleFromTurnEnd = ( + const rejectFromError = ( inflight: NonNullable, - reason: TurnEndReason, + reason: Extract, ): void => { - if (reason.kind === 'error') { - inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`)) - return - } - inflight.resolve(turnEndToStopReason(reason)) + inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`)) } // Emit only committed assistant text. Raw chunks, reasoning, tools, plans, @@ -173,10 +175,22 @@ export function apply(ctx: Context, config: AcpConfig): void { if (inflight.turn === undefined && event.data.trigger.kind === 'message' && event.data.trigger.source.kind === 'user') { inflight.turn = event.data.turn + } else if (inflight.pendingError !== undefined && event.data.trigger.kind === 'retry') { + // A recovery policy opened a retry turn on the failed history: the + // prompt rides it instead of rejecting on the failed turn's end. + inflight.turn = event.data.turn + inflight.pendingError = undefined } } else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { - record.inflight = undefined - settleFromTurnEnd(inflight, event.data.reason) + if (event.data.reason.kind === 'error') { + // Hold the rejection: request recovery may adopt the prompt with a + // successor turn; quiescence without one delivers this error. + inflight.turn = undefined + inflight.pendingError = event.data.reason + } else { + record.inflight = undefined + inflight.resolve(turnEndToStopReason(event.data.reason)) + } } } }) @@ -254,23 +268,47 @@ export function apply(ctx: Context, config: AcpConfig): void { const text = acpPromptToText(params.prompt) if (text.trim().length === 0) throw invalidParams('empty prompt') + // Not driving a retired agent is this bridge's contract: an + // agent-loop-only reload disposes the loop's agents while the bridge + // record survives, so validate the record against the live registry + // before sending — a disposed machine would accept the item silently. + if (ctx.agents.get(record.agent.id) !== record.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } const stopReason = await new Promise((resolve, reject) => { // Arm the slot before followup() so a listener-driven synchronous // turn cannot slip past correlation; a synchronous followup() - // failure (an agent disposed outside the bridge, e.g. an - // agent-loop-only reload) must free the slot again or the session + // failure (invalid input) must free the slot again or the session // would reject every later prompt as already in flight. - record.inflight = { resolve, reject, turn: undefined } + const inflight: NonNullable = { + resolve, reject, turn: undefined, pendingError: undefined, + } + record.inflight = inflight try { - record.agent.followup([{ type: 'text', text }]) + record.agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } }) + // The machine's send() contains listener failures and accepts + // any typed input; this guards a future synchronous throw so the + // slot cannot wedge. + /* v8 ignore start -- future-proofing guard, see above */ } catch (error: unknown) { record.inflight = undefined - // followup() throws only Errors (disposed agent / invalid input); - // the String arm is a defensive fallback for a non-Error throw. - /* v8 ignore next */ const detail = error instanceof Error ? error.message : String(error) throw internalError(`prompt was not queued: ${detail}`) } + /* v8 ignore stop */ + // Admission is pre-turn and retries outlive their failed turn, so a + // turnless slot settles only at quiescence: a held failure rejects + // (no retry adopted the prompt); no turn at all means admission + // discarded the prompt — report cancelled. + void record.agent.whenIdle().then(() => { + if (record.inflight !== inflight || inflight.turn !== undefined) return + record.inflight = undefined + if (inflight.pendingError !== undefined) { + rejectFromError(inflight, inflight.pendingError) + return + } + inflight.resolve('cancelled') + }) }) return { stopReason } }, diff --git a/packages/acp/acp/tests/codec.spec.ts b/packages/acp/acp/tests/codec.spec.ts index 2fdf544500..7f5441e4df 100644 --- a/packages/acp/acp/tests/codec.spec.ts +++ b/packages/acp/acp/tests/codec.spec.ts @@ -9,7 +9,6 @@ describe('ACP automation codec', () => { [{ kind: 'max-tokens' }, 'max_tokens'], [{ kind: 'aborted' }, 'cancelled'], [{ kind: 'disposed' }, 'cancelled'], - [{ kind: 'rejected', reason: 'blocked' }, 'cancelled'], [{ kind: 'interrupted' }, 'cancelled'], [{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'], ] diff --git a/packages/acp/acp/tests/dispose.spec.ts b/packages/acp/acp/tests/dispose.spec.ts index 1303cde0b2..ee57d51baf 100644 --- a/packages/acp/acp/tests/dispose.spec.ts +++ b/packages/acp/acp/tests/dispose.spec.ts @@ -21,7 +21,7 @@ describe('ACP connection ownership', () => { await harness.acpFiber.dispose() await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) - expect(agent.status).toBe('disposed') + expect(agent.status).toBe('idle') expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) @@ -44,7 +44,7 @@ describe('ACP connection ownership', () => { await harness.closeClientTransport() await harness.acpFiber.dispose() - expect(agent.status).toBe('disposed') + expect(agent.status).toBe('idle') expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() }) @@ -58,10 +58,10 @@ describe('ACP connection ownership', () => { await vi.waitFor(() => { expect(agent.status).toBe('running') }) await harness.abortClientTransport() - await vi.waitFor(() => { expect(agent.status).toBe('disposed') }) await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true) }) + expect(agent.status).toBe('idle') }) it('disconnect and plugin disposal share one quiescence boundary', async () => { @@ -73,7 +73,7 @@ describe('ACP connection ownership', () => { await vi.waitFor(() => { expect(agent.status).toBe('running') }) await Promise.all([harness.closeClientTransport(), harness.acpFiber.dispose()]) - expect(agent.status).toBe('disposed') + expect(agent.status).toBe('idle') expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) diff --git a/packages/acp/acp/tests/edges.spec.ts b/packages/acp/acp/tests/edges.spec.ts index 1a16647d01..e4929e59fb 100644 --- a/packages/acp/acp/tests/edges.spec.ts +++ b/packages/acp/acp/tests/edges.spec.ts @@ -49,7 +49,7 @@ describe('ACP automation output boundary', () => { sessionId: SessionId('foreign'), agentOptions: { provider: 'mock', model: 'mock' }, }) - agent.followup([{ type: 'text', text: 'go' }]) + agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }) await agent.whenIdle() expect(harness.updates).toHaveLength(0) }) diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index adef80adc2..77b49d80b0 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -77,7 +77,7 @@ describe('ACP prompt lifecycle', () => { it('rejects an ordinary plugin failure through the same prompt boundary', async () => { harness = await makeBridgeHarness({ script: [textResponse('must not run')] }) - harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') }) + harness.ctx.on('agent/step', () => { throw new Error('plugin pre-step failed') }) const sessionId = await newSession(harness) await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) .rejects.toThrow(/turn failed: plugin pre-step failed/) @@ -101,7 +101,7 @@ describe('ACP prompt lifecycle', () => { harness.ctx.on('agent/inbox/enqueue', (subject) => { if (subject === agent && !injected) { injected = true - agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'test' } }) + agent.inject({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }) } }) @@ -195,4 +195,40 @@ describe('ACP prompt lifecycle', () => { .resolves.toEqual({ stopReason: 'end_turn' }) await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') }) }) + + it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => { + harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] }) + // A recovery policy: schedule one retry for the failed request. + let retried = false + harness.ctx.on('agent/request-error', async (_subject) => { + if (!retried) { + retried = true + return { kind: 'retry' } + } + }) + const sessionId = await newSession(harness) + const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(result.stopReason).toBe('end_turn') + await vi.waitFor(() => { expect(messageText(harness!)).toBe('recovered') }) + }) + + it('a failed turn with no retry still rejects, at quiescence', async () => { + harness = await makeBridgeHarness({ script: [errorResponse('terminal boom')] }) + let offered = 0 + harness.ctx.on('agent/request-error', async () => { offered += 1 }) + const sessionId = await newSession(harness) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/turn failed: terminal boom/) + expect(offered).toBe(1) + }) + + it('an admission-blocked prompt settles cancelled instead of hanging', async () => { + harness = await makeBridgeHarness({ script: [] }) + harness.ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy said no' })) + const sessionId = await newSession(harness) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .resolves.toEqual({ stopReason: 'cancelled' }) + // The blocked prompt opened no turn and streamed nothing. + expect(messageText(harness)).toBe('') + }) }) diff --git a/packages/bash/README.i18n.yaml b/packages/bash/README.i18n.yaml index 6f9db27161..7c222fb6f5 100644 --- a/packages/bash/README.i18n.yaml +++ b/packages/bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 08b36270800cdd79c82d6781bbfb2e12e2dc2060 -README.zh.md: a98506a6cdf41e5b298b40e1b8e1faf0c4c917d2 +README.md: e60ad9b0e4c48cf35a2601e7dec4d2d50807707b +README.zh.md: 57c28b45cf713aeaac725edb70d1fc24912c35db diff --git a/packages/bash/README.md b/packages/bash/README.md index 08b3627080..e60ad9b0e4 100644 --- a/packages/bash/README.md +++ b/packages/bash/README.md @@ -6,8 +6,8 @@ The canonical three-package capability seam (see [capability seams](../../.agent | Package | Role | ctx key | |---|---|---| -| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary) | `ctx.bash` | -| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) | +| `bash/` | Abstract bash executor seam (interface + vocabulary; sandbox result facts carry the [`sandbox/`](../sandbox/README.md) seam's mode/enforcement vocabulary, and the managed-env/output vocabulary is re-exported from the [`subprocess/`](../subprocess/README.md) seam) | `ctx.bash` | +| `bash-local/` | Local `BashExecutor` implementation over the [`subprocess/`](../subprocess/README.md) service (command defaulting, deadlines, terminal env, background-read merge) | (registers `ctx.bash`) | | `bash-sandbox/` | Sandbox-consuming `BashExecutor` (wraps every command argv via `ctx.sandbox`, stamps denial/enforcement facts; extends `bash-local`'s mechanics) | (registers `ctx.bash`) | | `tool-bash/` | Model-facing `bash` schema; background processes register with the generic [`tasks/`](../tasks/README.md) runtime | (registers on `ctx.tools`) | diff --git a/packages/bash/README.zh.md b/packages/bash/README.zh.md index a98506a6cd..57c28b45cf 100644 --- a/packages/bash/README.zh.md +++ b/packages/bash/README.zh.md @@ -6,8 +6,8 @@ | 包 | 职责 | ctx key | |---|---|---| -| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇) | `ctx.bash` | -| `bash-local/` | 本地子进程 `BashExecutor` 实现 | (注册 `ctx.bash`) | +| `bash/` | 抽象 bash 执行器 seam(接口 + 词汇;沙箱结果事实携带 [`sandbox/`](../sandbox/README.md) seam 的模式/强制执行词汇,受管环境/输出词汇则从 [`subprocess/`](../subprocess/README.md) seam 重导出) | `ctx.bash` | +| `bash-local/` | 构建在 [`subprocess/`](../subprocess/README.md) 服务之上的本地 `BashExecutor` 实现(命令默认值补全、deadline、终端环境、后台读取合并) | (注册 `ctx.bash`) | | `bash-sandbox/` | 消费沙箱的 `BashExecutor`(通过 `ctx.sandbox` 包装每个命令 argv,标记拒绝/强制执行事实;扩展 `bash-local` 的机制) | (注册 `ctx.bash`) | | `tool-bash/` | 面向模型的 `bash` schema;后台进程注册到通用 [`tasks/`](../tasks/README.md) 运行时 | (注册到 `ctx.tools`) | diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index c7e9587289..f94d04f90e 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/bash-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 1668f33e8acf6d749d4d3753478c12d48a19ac3c -README.zh.md: 0e0a4ad41b532e39f6f2470aa981a08b6d6230c1 +README.md: 694b7a7686ea6c38da5a354ff6b6e6d2c4520706 +README.zh.md: aa6de87df48ee943ccdd2c6227ad977f596b5516 diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 1668f33e8a..694b7a7686 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c ` per call in its own process group, collects bounded output with size-limited full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group. +Local implementation of the `@deepseek-ai/dsh-bash` executor seam over the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) service: `LocalBashExecutor` spawns `bash -c ` per call as a managed process group through `ctx.subprocess`, and owns everything bash-shaped — command defaulting and caps, timeout/cancel classification, the model-friendly terminal environment, and the model-facing stdout/stderr merge for background reads. Group mechanics (bounded spill-backed output, credential scrub, kill escalation, disposal) are the subprocess service's. -The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package. +The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`. ## Config @@ -24,11 +24,11 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: -- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). After the main shell exits, inherited stdout/stderr pipes receive the same bounded drain grace so a surviving descendant cannot hold the command open indefinitely. ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. -- **Tail-keep truncation + bounded spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background tasks still use `maxOutputBytes`. A stream larger than `maxSpillBytes` discards its now-incomplete spill and returns only the marked truncated tail. If the final spill close reports a delayed writeback failure, the executor likewise withholds the path rather than advertising an incomplete file. -- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`) and all ambient `DSH_*` names, then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. A spec's ordinary `env` is merged after the scrub but rejects `DSH_*`; managed `dshEnv` rejects ordinary names and merges last, preventing stale nested-harness identity. Supplied stdin is written and closed; otherwise fd 0 is `/dev/null`. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). -- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), the handle's `readOutput()` is incremental with whole-stream byte offsets, and disposal kills every running process and awaits its exit. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. +- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/index.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. +- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs` (default 3s — OpenCode's escalation). Process-group kills, the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. +- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). +- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results, merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +- **Background processes** — `start()` returns a live `BashProcess` handle immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), and the handle's `readOutput()` merges the service's offset-based stdout/stderr reads into one marked-section delta with a consuming cursor. A still-running process belongs to the subprocess service, so it survives executor reloads and dies (killed and joined) with the service's disposal. Everything task-shaped (ids, ownership, polling, notices) lives in the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with — this executor never sees a session or a registry. ## Model Experience @@ -42,8 +42,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. - **Unconfined by itself** — this executor always runs commands with the harness process's authority; deployments needing confinement compose [`dsh-bash-sandbox`](../bash-sandbox/README.md), while per-call allow/deny/ask policy belongs on `tools/pre-execute`. - **No persistent shell or PTY** — every call starts a fresh non-login `bash -c`; cwd-only persistence and interactive terminal sessions remain deferred until a real workflow requires them. -- **POSIX-only** — the `bash` binary, detached process groups, group kills, and SIGTERM→SIGKILL escalation are hardcoded; Windows is unsupported. -- **The credential scrub is a name heuristic** — `*KEY*`/`*SECRET*`/`*TOKEN*` only; differently-named secrets (e.g. `*PASSWORD*`) pass through, and a whitelist for over-scrubbed vars is noted future work. -- **Completed spill files are not deleted** — bounded full-output recovery files (and the private per-process spill dir) accumulate under the OS tmpdir until something external cleans them; oversize incomplete spills are discarded and deletion is attempted immediately, but a cleanup failure can leave a bounded file behind. +- **POSIX-only** — the `bash` binary is hardcoded, and the underlying service's group semantics are POSIX; Windows is unsupported. +- **A background spawn-failure note is single-delivery** — the subprocess service buffers no output for a process that never ran, so the executor injects `spawn failed: …` into exactly one `readOutput()` delta; a reader that discards that delta cannot recover it. -The raw process handling lives in `src/run.ts`; `src/index.ts` is the service wiring. +Scrub-heuristic and spill-retention caveats live with [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md), which owns those mechanics. diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index 0e0a4ad41b..aa6de87df4 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -`@deepseek-ai/dsh-bash` 执行器 seam 的本地子进程实现:`LocalBashExecutor` 每次调用都会在独立进程组中 spawn `bash -c `,收集有界输出,并用限制大小的完整流 spill 文件保留超量内容,随后针对整个进程组从 SIGTERM 逐步升级为 SIGKILL。 +`@deepseek-ai/dsh-bash` 执行器 seam 的本地实现,构建在 [`@deepseek-ai/dsh-subprocess`](../../subprocess/subprocess/README.md) 服务之上:`LocalBashExecutor` 每次调用都通过 `ctx.subprocess` 把 `bash -c ` 作为受管进程组 spawn,并拥有所有 bash 形态的职责(命令默认值补全与上限、超时与取消分类、适合模型的终端环境,以及后台读取时面向模型的 stdout/stderr 合并)。进程组机制(以 spill 文件兜底的有界输出、凭据清除、kill 升级、dispose(资源释放))归进程管理器服务所有。 -包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`;子进程管道细节保留在该实现包内部。 +包根目录导出默认与具名的 `LocalBashExecutor` 插件及其 `Config`。 ## 配置 @@ -24,11 +24,11 @@ 设计时调研了 Claude Code、OpenCode、Codex 和 pi 的 bash 工具,主要取舍如下: -- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/run.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 -- **使用逐步升级终止整个进程组**:子进程使用 `detached` spawn(拥有独立进程组);终止时先向该组发送 SIGTERM,经过 `graceMs` 宽限期后再发送 SIGKILL(默认 3 秒,沿用 OpenCode 的升级策略;管道与子 shell 会随父进程一起结束)。主 shell 退出后,继承的 stdout/stderr 管道也只获得同样有界的排空宽限期,因此存活的后代进程无法无限期地阻止命令结束。系统会容忍 ESRCH;脱离该组重新挂载的 daemon 仍可能存活,这与调研工具的局限相同。 -- **保留尾部的截断 + 有界 spill 文件**:输出超过 `maxOutputBytes` 后,内存中保留尾部(错误/结果通常聚集在末尾,沿用 pi/OpenCode 的理由),同时将完整流追加到临时文件,并在可用时报告该路径。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台任务仍使用 `maxOutputBytes`。某个流大于 `maxSpillBytes` 时,会丢弃已不完整的 spill,仅返回带截断标记的尾部。如果最终关闭 spill 时报告延迟写回失败,执行器同样不会公布路径,以免声称存在不完整的文件。 -- **适合模型的环境变量 + 凭证清理**:以 `process.env` 为基础,移除形似凭证的变量(`*KEY*`/`*SECRET*`/`*TOKEN*`)和所有环境中的 `DSH_*` 名称,再设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果。spec 的普通 `env` 在清理后合并,但会拒绝 `DSH_*`;受管 `dshEnv` 会拒绝普通名称并最后合并,防止遗留嵌套 harness 身份。提供的 stdin 会被写入后关闭;否则 fd 0 指向 `/dev/null`。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 -- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 使用全流字节偏移量进行增量读取;dispose 会终止每个运行中的进程并等待其退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 +- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`(行为确定,不读取 rc 文件)。调研的四种工具均会每次调用单独 spawn。`XXX(stateful-shell)` 位于 `src/index.ts`,记录了两种已验证的有状态设计(Claude Code 仅持久化 cwd;Codex 使用 PTY exec 会话),供真实工作流程需要时采用。 +- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`(默认 3 秒,沿用 OpenCode 的升级策略)。进程组终止、退出后的管道排空宽限期、尾部保留截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 +- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自行发出信号终止的命令两者皆不报告(见[超时库 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 +- **适合模型的终端环境**:设置 `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat`(Codex 硬编码的集合),防止分页器与 ANSI 颜色破坏结果;这些条目作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +- **后台进程**:`start()` 会立即返回实时 `BashProcess` 句柄,不应用超时(Claude Code 在转为后台时会解除超时);句柄的 `readOutput()` 把服务基于偏移量的 stdout/stderr 读取合并为一条带标记分节的增量,由一个消费游标驱动。仍在运行的进程归进程管理器服务所有,因此它能在执行器重载后存活,并随服务的 dispose 被终止且等待退出。所有具有任务形态的事项(id、所有权、轮询、通知)都属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄;本执行器不会接触会话或注册表。 ## 模型体验 @@ -42,8 +42,7 @@ - **自身不受约束**:此执行器始终以 harness 进程的权限运行命令;需要限制的部署可以组合 [`dsh-bash-sandbox`](../bash-sandbox/README.md),每次调用的 allow/deny/ask 策略则属于 `tools/pre-execute`。 - **没有持久 shell 或 PTY**:每次调用都启动新的非登录 `bash -c`;仅持久化 cwd 与交互式终端会话均继续暂缓,直到真实工作流程需要它们。 -- **仅支持 POSIX**:`bash` 二进制、独立进程组、进程组终止以及 SIGTERM→SIGKILL 升级都已硬编码;不支持 Windows。 -- **凭证清理依赖名称启发式规则**:只匹配 `*KEY*`/`*SECRET*`/`*TOKEN*`;名称不同的 secret(例如 `*PASSWORD*`)会继续传递,对误删变量引入白名单属于已记录的后续工作。 -- **不会删除已完成的 spill 文件**:有界的完整输出恢复文件(以及每个进程的私有 spill 目录)会在 OS tmpdir 下累积,直到外部机制进行清理;超大的不完整 spill 会被丢弃并立即尝试删除,但清理失败可能留下一个有界文件。 +- **仅支持 POSIX**:`bash` 二进制已硬编码,底层服务的进程组语义也是 POSIX 的;不支持 Windows。 +- **后台 spawn 失败提示只交付一次**:进程管理器不会为从未真正运行的进程缓冲任何输出,因此执行器把 `spawn failed: …` 注入恰好一个 `readOutput()` 增量;丢弃了该增量的读取方无法再恢复它。 -原始进程处理位于 `src/run.ts`;`src/index.ts` 负责服务接线。 +凭据清除启发式规则与 spill 保留的注意事项随 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 记录;这些机制归它所有。 diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index 152c2db28b..bacc7ac92d 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -29,6 +29,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -38,6 +39,8 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 2c25701fb9..0f5a1b4e4d 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -1,17 +1,39 @@ /** - * Local-subprocess implementation of the bash executor seam. Each command runs - * as `bash -c` in its own process group; disposal kills and joins live groups. - * Execution policy belongs in `tools/pre-execute` or a sandboxing executor. + * Local implementation of the bash executor seam over the subprocess + * seam. Each command runs as `bash -c` in a managed process group spawned + * through `ctx.subprocess`; this executor owns command defaulting, deadlines + * and cause classification, the model-friendly terminal environment, and the + * model-facing stdout/stderr merge for background reads. Execution policy + * belongs in `tools/pre-execute` or a sandboxing executor. * @module @deepseek-ai/dsh-bash-local */ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' +import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' -import { DEFAULT_GRACE_MS, DEFAULT_MAX_SPILL_BYTES, runBash } from './run.ts' -import type { RunInternals, RunningBash } from './run.ts' + +/** + * Model-friendly environment overrides: disable colors, pagers, and + * interactive terminal features that would garble tool output (the same set + * Codex hardcodes; Claude Code achieves it via TERM=dumb). Bash-tool policy — + * merged first into the spawn's explicit env, so a trusted caller's own entry + * still wins; the subprocess service applies its credential scrub independently. + */ +export const ENV_OVERRIDES = { + NO_COLOR: '1', + TERM: 'dumb', + PAGER: 'cat', + GIT_PAGER: 'cat', +} as const + +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ +const DEFAULT_GRACE_MS = 3_000 + +/** Default per-stream spill cap (the `maxSpillBytes` config). */ +const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 /** Plugin config (all optional — `static Config` supplies the defaults). */ export interface Config { @@ -32,6 +54,16 @@ export interface Config { /** The shape after schemastery applied the defaults (cwd has none). */ type ResolvedConfig = Required> & Pick +/** Project a settled collect-mode reader into the final CollectedOutput shape. */ +function finalOutput(reader: SubprocessOutputReader): CollectedOutput { + const read = reader.readFrom(0) + return { + text: read.text, + truncated: read.lossy, + ...read.spillPath !== undefined ? { spillPath: read.spillPath } : {}, + } +} + function assertPositiveFinite(name: string, value: number): void { if (!Number.isFinite(value) || value <= 0) { throw new Error(`bash-local: ${name} must be a positive finite number`) @@ -39,10 +71,15 @@ function assertPositiveFinite(name: string, value: number): void { } /** - * Local bash executor with bounded output, spill files, and process-group - * `SIGTERM` to `SIGKILL` escalation. + * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and + * process-group SIGTERM→SIGKILL escalation are the subprocess service's + * mechanics; this executor supplies their configured budgets per spawn, so a + * still-running background process stays managed (killed and joined at + * composition teardown) even across an executor reload. */ export class LocalBashExecutor extends BashExecutor { + static inject = ['subprocess'] + static Config: z = z.object({ cwd: z.string(), timeoutMs: z.number().default(120_000), @@ -52,11 +89,6 @@ export class LocalBashExecutor extends BashExecutor { graceMs: z.number().default(DEFAULT_GRACE_MS), }) - /** Live processes retained only so disposal can kill and join them. */ - private live = new Map() - /** Test seam: spill knobs forwarded to runBash. */ - internals: RunInternals = {} - /** Validated config (schemastery applied the defaults before construction). */ readonly config: ResolvedConfig @@ -69,17 +101,6 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) assertPositiveFinite('graceMs', this.config.graceMs) - ctx.effect(() => async () => { - // Await closure so even a TERM-trapping child cannot outlive the fiber. - const pending: Promise[] = [] - for (const [proc, running] of this.live) { - proc.status = 'killed' - running.kill() - pending.push(proc.done) - } - this.live.clear() - await Promise.all(pending) - }, 'local bash teardown') } /** @@ -105,7 +126,7 @@ export class LocalBashExecutor extends BashExecutor { stdoutMaxBytes, ...request.signal ? { signal: request.signal } : {}, // Carry stdin/ordinary env/trusted dshEnv through verbatim — optional, - // no config default. run.ts owns the scrub and merge order. + // no config default. The subprocess service owns the scrub and merge order. ...request.stdin !== undefined ? { stdin: request.stdin } : {}, ...request.env !== undefined ? { env: request.env } : {}, ...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {}, @@ -116,41 +137,71 @@ export class LocalBashExecutor extends BashExecutor { } } + /** Map one resolved bash spec onto a fully-specified subprocess spawn. */ + // XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. + private spawnSpec(spec: BashExecSpec, stdoutMaxBytes: number, signal: AbortSignal | undefined): SubprocessSpawnSpec { + const collect = (maxBytes: number): SubprocessCollect => + ({ maxBytes, spill: { maxBytes: this.config.maxSpillBytes } }) + return { + argv: ['bash', '-c', spec.command], + cwd: spec.workdir, + stdio: { + stdin: spec.stdin !== undefined ? { data: spec.stdin } : 'ignore', + stdout: collect(stdoutMaxBytes), + stderr: collect(this.config.maxOutputBytes), + }, + graceMs: this.config.graceMs, + signal, + // One explicit env map for the seam, layered so the trusted dshEnv + // snapshot beats both the caller's env and the terminal overrides; the + // subprocess service merges the whole map after its ambient scrub. + env: { ...ENV_OVERRIDES, ...spec.env, ...spec.dshEnv }, + } + } + + /** The collect-mode readers the executor itself requested (present by construction). */ + private static collected(handle: SubprocessHandle): { stdout: SubprocessOutputReader; stderr: SubprocessOutputReader } { + const { stdout, stderr } = handle.collected + /* v8 ignore start -- collect dispositions expose both readers by the seam contract; defensive. */ + if (stdout === undefined || stderr === undefined) { + throw new Error('bash-local: subprocess implementation dropped a requested collect stream') + } + /* v8 ignore stop */ + return { stdout, stderr } + } + async run(spec: BashExecSpec): Promise { // One deadline combines timeout and upstream cancellation; disposal clears its timer. using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT') - const outcome = await runBash({ - command: spec.command, - cwd: spec.workdir, - stdoutMaxBytes: spec.stdoutMaxBytes, - stderrMaxBytes: this.config.maxOutputBytes, - maxSpillBytes: this.config.maxSpillBytes, - graceMs: this.config.graceMs, - signal: d.signal, - stdin: spec.stdin, - env: spec.env, - dshEnv: spec.dshEnv, - }, this.internals).done + const handle = this.ctx.subprocess.spawn(this.spawnSpec(spec, spec.stdoutMaxBytes, d.signal)) + const outcome = await handle.done + const collected = LocalBashExecutor.collected(handle) // Only this executor's timeout reason counts as timedOut; outer deadlines count as aborts. const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined const aborted = d.signal.aborted && !timedOut - return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs } + return { + ...outcome, + timedOut, + aborted, + timeoutMs: spec.timeoutMs, + stdout: finalOutput(collected.stdout), + stderr: finalOutput(collected.stderr), + } } start(spec: BashExecSpec): BashProcess { // Background runs ignore timeoutMs; callers stop them through kill() or spec.signal. - const running = runBash({ - command: spec.command, - cwd: spec.workdir, - stdoutMaxBytes: this.config.maxOutputBytes, - stderrMaxBytes: this.config.maxOutputBytes, - maxSpillBytes: this.config.maxSpillBytes, - graceMs: this.config.graceMs, - signal: spec.signal, - stdin: spec.stdin, - env: spec.env, - dshEnv: spec.dshEnv, - }, this.internals) + const running = this.ctx.subprocess.spawn(this.spawnSpec(spec, this.config.maxOutputBytes, spec.signal)) + const collected = LocalBashExecutor.collected(running) + + // A spawn failure produces no process output, so the subprocess service has nothing + // to buffer; the note is delivered exactly once through the read path. + let spawnFailureNote: string | undefined + const consumeSpawnFailure = (): string => { + const note = spawnFailureNote ?? '' + spawnFailureNote = undefined + return note + } let stdoutOffset = 0 let stderrOffset = 0 @@ -165,26 +216,27 @@ export class LocalBashExecutor extends BashExecutor { } proc.exitCode = outcome.exitCode proc.signal = outcome.signal - this.onProcessDone(proc, running.stderr.readFrom(0).text) - this.live.delete(proc) + this.onProcessDone(proc, collected.stderr.readFrom(0).text) }, (error: unknown) => { // Background spawn failures settle as killed and surface through the read path. proc.status = 'killed' - running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`)) - this.onProcessDone(proc, running.stderr.readFrom(0).text) - this.live.delete(proc) + spawnFailureNote = `spawn failed: ${String(error)}` + this.onProcessDone(proc, spawnFailureNote) }), readOutput: (): BashProcessRead => { - const out = running.stdout.readFrom(stdoutOffset) - const err = running.stderr.readFrom(stderrOffset) + const out = collected.stdout.readFrom(stdoutOffset) + const err = collected.stderr.readFrom(stderrOffset) stdoutOffset = out.nextOffset stderrOffset = err.nextOffset + // A failed spawn never produced process output, so the note and real + // stderr text are mutually exclusive. + const errText = err.text.length > 0 ? err.text : consumeSpawnFailure() // Single newline between sections: stdout chunks usually end with one // already; add it only when missing. const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : '' const delta = out.text - + (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '') + + (errText.length > 0 ? `${separator}[stderr]\n${errText}` : '') return { delta, lossy: out.lossy || err.lossy, @@ -195,11 +247,10 @@ export class LocalBashExecutor extends BashExecutor { kill: (): boolean => { if (proc.status !== 'running') return false proc.status = 'killed' - running.kill() + running.terminate() return true }, } - this.live.set(proc, running) return proc } diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts deleted file mode 100644 index 600e920c96..0000000000 --- a/packages/bash/bash-local/src/run.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * Process plumbing for the local bash executor: detached process-group spawn, - * tail-keep output with spill files, and SIGTERM→SIGKILL escalation. This layer - * reacts to an abort signal; the executor owns deadlines and classifies causes. - * @module dsh-bash-local/run - */ - -import { type ChildProcessByStdio, spawn } from 'node:child_process' -import type { Readable, Writable } from 'node:stream' -import { randomBytes } from 'node:crypto' -import { closeSync, mkdtempSync, openSync, unlinkSync, writeSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-bash' - -/** - * Model-friendly environment overrides: disable colors, pagers, and - * interactive terminal features that would garble tool output (the same set - * Codex hardcodes; Claude Code achieves it via TERM=dumb). - */ -export const ENV_OVERRIDES = { - NO_COLOR: '1', - TERM: 'dumb', - PAGER: 'cat', - GIT_PAGER: 'cat', -} as const - -/** - * Credential-shaped env vars are NOT forwarded to commands (the harness's - * own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or - * spill files). Same default pattern as Codex's env policy; a future config - * can whitelist specific vars when a workflow genuinely needs one. - */ -export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i - -/** - * Build a child environment from scrubbed ambient values, terminal overrides, - * ordinary caller entries, and a managed `DSH_*` snapshot. Ambient managed - * names are removed; ordinary and managed entries reject the other channel's - * namespace before `dshEnv` merges last. - * @param extra - caller entries; `DSH_*` names are rejected. - * @param dshEnv - managed entries; non-`DSH_*` names are rejected. - * @returns the environment to hand to `spawn` for the child process. - */ -export function childEnv( - extra?: Readonly>, - dshEnv?: DshEnvironment, -): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} - for (const [key, value] of Object.entries(process.env)) { - if (!SENSITIVE_ENV_PATTERN.test(key) && !key.startsWith(DSH_ENV_PREFIX)) env[key] = value - } - for (const key of Object.keys(extra ?? {})) { - if (key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`ordinary bash env cannot set reserved variable "${key}"; use dshEnv`) - } - } - for (const key of Object.keys(dshEnv ?? {})) { - if (!key.startsWith(DSH_ENV_PREFIX)) { - throw new Error(`managed bash env cannot set ordinary variable "${key}"; use env`) - } - } - return { ...env, ...ENV_OVERRIDES, ...extra, ...dshEnv } -} - -/** What to run and under which limits (resolved — no defaults in here). */ -export interface SpawnSpec { - command: string - cwd: string - /** Stdout in-memory cap; overflow spills to disk (tail kept in memory). */ - stdoutMaxBytes: number - /** Stderr in-memory cap; overflow spills to disk (tail kept in memory). */ - stderrMaxBytes: number - /** Per-stream spill-file cap; larger streams retain only their in-memory tail. */ - maxSpillBytes: number - /** Grace period for kill escalation and for inherited pipes after shell exit. */ - graceMs: number - /** - * Abort signal — kills the process group when it fires. The executor owns - * timing: `run()` passes a fused timeout/cancel deadline signal (see - * `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal. - * runBash only listens and kills; it does NOT classify why (the executor - * reads the signal's reason afterward). - */ - signal?: AbortSignal | undefined - /** - * Bytes to write to the child's stdin, then close it. Absent (or empty) - * leaves stdin closed/empty. Set by in-process plugins (the hooks bridges); - * the model-facing `dsh-tool-bash` tool does not thread model input here. - */ - stdin?: string | undefined - /** - * Ordinary environment entries merged after the credential scrub and - * terminal overrides. `DSH_*` names are rejected and belong in `dshEnv`. - */ - env?: Record | undefined - /** Harness-owned entries; non-`DSH_*` names are rejected before spawn. */ - dshEnv?: DshEnvironment | undefined -} - -/** - * Raw outcome of one closed process (before result shaping). Deliberately - * carries NO timeout/cancel classification: runBash kills on abort but does not - * decide why — the executor's `run()`/`start()` reads the deadline signal it - * owns to classify `timedOut`/`aborted` (see the package README). - */ -export interface SpawnOutcome { - exitCode: number | null - signal: NodeJS.Signals | null - stdout: CollectedOutput - stderr: CollectedOutput -} - -/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */ -export interface RunInternals { - /** Directory for spill files (defaults to the OS temp dir). */ - spillDir?: string -} - -/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ -export const DEFAULT_GRACE_MS = 3_000 - -/** Default per-stream spill cap (the `maxSpillBytes` config). */ -export const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024 - -let spillCounter = 0 -let defaultSpillDir: string | undefined - -/** - * The default spill location: a private (0700) per-process directory under - * the OS tmpdir, created lazily. Predictable world-readable paths would let - * other local users read command output or pre-create symlinks. - */ -function privateSpillDir(): string { - defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-')) - return defaultSpillDir -} - -/** - * Collects one stream with a bounded in-memory tail. On first overflow a - * spill file is created and every chunk (including those already collected) - * is appended there while the full stream remains within `maxSpillBytes`. - * - * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the - * end of command output; the spill file covers the head. - */ -export class OutputCollector { - private chunks: Buffer[] = [] - private bytes = 0 - private dropped = false - private spillFd: number | undefined - private spillFile: string | undefined - private spillDisabled = false - /** Total bytes ever pushed (not just retained). */ - private total = 0 - - constructor( - private readonly maxBytes: number, - private readonly maxSpillBytes: number, - private readonly label: string, - private readonly spillDir: string, - ) {} - - /** - * Ingest one stream chunk, counting it toward the whole-stream total. On - * first overflow of the in-memory cap a spill file is opened and every chunk - * (already-collected ones included) is appended there from then on; the - * in-memory tail then drops whole chunks from its head (or the head of a - * single over-cap chunk) until it fits the cap again. - * @param chunk - the raw bytes from one stream 'data' event. - */ - push(chunk: Buffer): void { - this.total += chunk.length - const overflows = this.bytes + chunk.length > this.maxBytes - if (!this.spillDisabled && (overflows || this.spillFd !== undefined)) this.spillAll(chunk) - this.chunks.push(chunk) - this.bytes += chunk.length - while (this.bytes > this.maxBytes && this.chunks.length > 1) { - // Drop whole chunks from the head; pipe chunks are small (≤64KiB), so - // the retained tail tracks the cap closely enough for a model-facing - // truncation boundary. (length > 1 was just checked — shift() returns.) - const head = this.chunks.shift() as Buffer - this.bytes -= head.length - this.dropped = true - } - if (this.bytes > this.maxBytes && this.chunks.length === 1) { - // A single chunk larger than the cap: keep its tail. - const only = this.chunks[0] as Buffer - this.chunks[0] = only.subarray(only.length - this.maxBytes) - this.bytes = this.maxBytes - this.dropped = true - } - } - - /** Open the spill file lazily and append `chunk` (and any prior chunks once). */ - private spillAll(chunk: Buffer): void { - if (this.total > this.maxSpillBytes) { - this.discardSpill() - return - } - if (this.spillFd === undefined) { - // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any - // existing path, symlink or not) + owner-only mode: defeats spill-path - // prediction and symlink planting in shared tmp dirs. - this.spillFile = join( - this.spillDir, - `dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`, - ) - this.spillFd = openSync(this.spillFile, 'wx', 0o600) - for (const prior of this.chunks) writeSync(this.spillFd, prior) - } - writeSync(this.spillFd, chunk) - } - - /** Stop spilling and remove the file once it can no longer hold the complete stream. */ - private discardSpill(): void { - const fd = this.spillFd - const file = this.spillFile - this.spillFd = undefined - this.spillFile = undefined - this.spillDisabled = true - if (fd !== undefined) { - try { - closeSync(fd) - } catch { - // Retain the descriptor so finalize can retry the failed close. - this.spillFd = fd - } - } - if (file !== undefined) { - try { - unlinkSync(file) - } catch { - // A failed unlink leaves at most maxSpillBytes behind, never an unbounded file. - } - } - } - - /** - * Incremental read in whole-stream byte coordinates: returns everything - * pushed since `fromByte`. When `fromByte` has already slid out of the - * in-memory tail window, the read is `lossy` — it returns the whole - * retained tail and the gap is only recoverable from the spill file. - * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read). - * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created. - */ - readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } { - const windowStart = this.total - this.bytes - const buffer = Buffer.concat(this.chunks) - const lossy = fromByte < windowStart - const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart) - return { - text: slice.toString('utf8'), - nextOffset: this.total, - lossy, - ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, - } - } - - /** - * Close the spill file (if any) and return the final output. A failed close - * (delayed writeback fault) stops advertising the spill path — the file may - * be missing its tail — but still returns the in-memory result. - * @returns the final collected output: tail text, truncation flag, and the spill path when intact. - */ - finalize(): CollectedOutput { - if (this.spillFd !== undefined) { - try { - closeSync(this.spillFd) - } catch { - // A delayed writeback failure makes the spill unreliable; keep finalize - // total but stop advertising that file. - this.spillFile = undefined - } - this.spillFd = undefined - } - return { - text: Buffer.concat(this.chunks).toString('utf8'), - truncated: this.dropped, - ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {}, - } - } -} - -/** - * Send `sig` to a detached process group. Never throws: delivery races process - * exit and may run in a timer callback, so failures are contained and a - * non-positive pid is a no-op. - * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op. - * @param sig - the signal to deliver to the whole group. - */ -export function killGroup(pid: number, sig: NodeJS.Signals): void { - if (pid <= 0) return - try { - process.kill(-pid, sig) - } catch { - // Swallow: see contract above. - } -} - -/** - * A live bash child process: the promise resolves when the process closes; - * `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group. - */ -export interface RunningBash { - /** Process id (group leader); -1 when the spawn itself failed. */ - readonly pid: number - /** stdout/stderr collectors (live — background polling reads incrementally). */ - readonly stdout: OutputCollector - readonly stderr: OutputCollector - /** Resolves when the process closes; rejects only for spawn-level failures. */ - readonly done: Promise - /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */ - kill(): void -} - -/** - * Spawn one isolated `bash -c` process group and collect its output. - * Runtime exits resolve as {@link SpawnOutcome}; only spawn failures reject. - * @param spec - fully resolved command, cwd, limits, and cancellation. - * @param internals - test-only process and spill-directory overrides. - * @returns live process handle and outcome promise. - */ -// XXX(stateful-shell): evaluate persistent cwd or PTY sessions when workflows require shell state. -export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash { - const spillDir = internals.spillDir ?? privateSpillDir() - - if (spec.signal?.aborted) { - throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`) - } - - // Keep absent stdin as /dev/null; literal tuples preserve non-null output types. - const env = childEnv(spec.env, spec.dshEnv) - const child: ChildProcessByStdio = spec.stdin !== undefined - ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }) - : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true }) - - const stdout = new OutputCollector(spec.stdoutMaxBytes, spec.maxSpillBytes, 'stdout', spillDir) - const stderr = new OutputCollector(spec.stderrMaxBytes, spec.maxSpillBytes, 'stderr', spillDir) - child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) }) - child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) }) - - let graceTimer: NodeJS.Timeout | undefined - - // Failed spawns use pid -1 so kill remains a no-op. - const pid = child.pid ?? -1 - - const kill = (): void => { - if (graceTimer !== undefined) return // escalation already in flight - killGroup(pid, 'SIGTERM') - graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) - } - - // The executor owns timeout classification; this layer only reacts to abort. - const onAbort = (): void => { kill() } - spec.signal?.addEventListener('abort', onAbort, { once: true }) - - // Stdin writes are best-effort; process exit and captured output remain authoritative. - if (child.stdin !== null) { - child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ }) - child.stdin.end(spec.stdin) - } - - const done = new Promise((resolve, reject) => { - let settled = false - let pipeDrainTimer: NodeJS.Timeout | undefined - const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => { - if (settled) return - settled = true - child.stdout.destroy() - child.stderr.destroy() - cleanup() - resolve({ - exitCode, - signal, - stdout: stdout.finalize(), - stderr: stderr.finalize(), - }) - } - child.on('error', (error) => { - // No meaningful close outcome follows a spawn failure. - settled = true - cleanup() - reject(error) - }) - child.on('exit', (exitCode, signal) => { - pipeDrainTimer = setTimeout(() => { settle(exitCode, signal) }, spec.graceMs) - }) - child.on('close', settle) - function cleanup(): void { - if (graceTimer !== undefined) clearTimeout(graceTimer) - if (pipeDrainTimer !== undefined) clearTimeout(pipeDrainTimer) - spec.signal?.removeEventListener('abort', onAbort) - } - }) - - return { pid, stdout, stderr, done, kill } -} diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index 2e24addb3b..1395b48958 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -4,16 +4,18 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { BashProcess } from '@deepseek-ai/dsh-bash' const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) async function setup(config: ConstructorParameters[1] = {}) { const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } // A short kill grace via the REAL config path, so escalation tests stay fast. await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } return { ctx, bash } } @@ -293,44 +295,52 @@ describe('LocalBashExecutor.start (background process handles)', () => { }) }) -describe('LocalBashExecutor disposal', () => { - it('disposing the fiber kills running processes and AWAITS their exit (no orphans, SIGKILL escalation included)', async () => { +describe('process lifecycle ownership (the subprocess service, not the executor)', () => { + it('a background process survives executor-fiber disposal and dies with the subprocess service', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + const executorFiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } // The child prints its own pid ($$ = the detached bash group leader) so // the test can probe liveness through the public read surface alone. - const proc = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo $$; sleep 60' })) + const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' })) const pid = Number((await readUntil(proc, '\n')).trim()) expect(Number.isInteger(pid) && pid > 0).toBe(true) - await fiber.dispose() - // Disposal itself waited: the pid must already be gone, no grace left — - // even for a TERM-trapping child held until the SIGKILL escalation landed. + // Executor reload/disposal leaves background work running — the + // handle stays live and readable, mirroring the task runtime's + // registrations-outlive-producer-fibers contract. + await executorFiber.dispose() + expect(proc.status).toBe('running') + expect(() => process.kill(pid, 0)).not.toThrow() + + // Service disposal kills the group and AWAITS its exit (no orphans). + await managerFiber.dispose() expect(() => process.kill(pid, 0)).toThrow() - expect(proc.status).toBe('killed') await proc.done + expect(proc.status).toBe('killed') }) - it('settled processes already left the live map: dispose does not touch them', async () => { + it('service disposal escalates to SIGKILL for TERM-trapping children and settles handles', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) + const managerFiber = await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } + await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir } const finished = bash.start(bash.resolve({ command: 'echo done' })) await finished.done expect(finished.status).toBe('completed') - const running = bash.start(bash.resolve({ command: 'sleep 60' })) + const trapping = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo armed; sleep 60' })) + await readUntil(trapping, 'armed') - await fiber.dispose() - // The teardown marks every LIVE entry killed; a settled process had - // already left the map, so its status stays completed. + await managerFiber.dispose() + // A settled process was untouched; the live one died by escalation. expect(finished.status).toBe('completed') - expect(running.status).toBe('killed') - await running.done - expect(running.signal).toBe('SIGTERM') + await trapping.done + expect(trapping.status).toBe('killed') + expect(trapping.signal).toBe('SIGKILL') }) }) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts deleted file mode 100644 index 91afd1aede..0000000000 --- a/packages/bash/bash-local/tests/run.spec.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { mkdtempSync, readFileSync, statSync, unlinkSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { describe, expect, it, vi } from 'vitest' -import type { DshEnvironment } from '@deepseek-ai/dsh-bash' -import { killGroup, OutputCollector, runBash } from '../src/run.ts' -import type { RunningBash } from '../src/run.ts' - -const { failNextClose, failNextUnlink } = vi.hoisted(() => ({ - failNextClose: { value: false }, - failNextUnlink: { value: false }, -})) -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - closeSync(fd: number): void { - if (failNextClose.value) { - failNextClose.value = false - throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' }) - } - actual.closeSync(fd) - }, - unlinkSync(path: Parameters[0]): void { - if (failNextUnlink.value) { - failNextUnlink.value = false - throw Object.assign(new Error('simulated EIO on unlink'), { code: 'EIO' }) - } - actual.unlinkSync(path) - }, - } -}) - -const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-')) - -function spec(command: string, overrides: Partial[0]> = {}) { - return { - command, - cwd: process.cwd(), - stdoutMaxBytes: 64_000, - stderrMaxBytes: 64_000, - maxSpillBytes: 64 * 1024 * 1024, - graceMs: 3_000, - ...overrides, - } -} - -/** Poll until a pid no longer exists (kill(pid, 0) throws ESRCH). */ -async function waitGone(pid: number, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - process.kill(pid, 0) - } catch { - return - } - await new Promise(resolve => setTimeout(resolve, 20)) - } - throw new Error(`pid ${pid} still alive after ${timeoutMs}ms`) -} - -async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (running.stdout.readFrom(0).text.includes(expected)) return - await new Promise(resolve => setTimeout(resolve, 20)) - } - throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`) -} - -async function waitForPidFile(path: string, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - const pid = Number(readFileSync(path, 'utf8').trim()) - if (Number.isSafeInteger(pid) && pid > 0) return pid - } catch { - // The child shell has not written the pid file yet. - } - await new Promise(resolve => setTimeout(resolve, 20)) - } - throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`) -} - -describe('runBash', () => { - it('captures stdout on success', async () => { - const result = await runBash(spec('echo hello')).done - expect(result.exitCode).toBe(0) - expect(result.signal).toBeNull() - expect(result.stdout.text).toBe('hello\n') - expect(result.stdout.truncated).toBe(false) - expect(result.stderr.text).toBe('') - }) - - it('captures stderr separately', async () => { - const result = await runBash(spec('echo oops >&2')).done - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('') - expect(result.stderr.text).toBe('oops\n') - }) - - it('captures both streams', async () => { - const result = await runBash(spec('echo out; echo err >&2')).done - expect(result.stdout.text).toBe('out\n') - expect(result.stderr.text).toBe('err\n') - }) - - it('reports non-zero exit codes', async () => { - const result = await runBash(spec('exit 42')).done - expect(result.exitCode).toBe(42) - expect(result.signal).toBeNull() - }) - - it('applies model-friendly env overrides', async () => { - const result = await runBash(spec('echo "$NO_COLOR/$TERM/$PAGER"')).done - expect(result.stdout.text).toBe('1/dumb/cat\n') - }) - - it('runs in the requested cwd', async () => { - const result = await runBash(spec('pwd', { cwd: '/tmp' })).done - expect(result.stdout.text.trim()).toMatch(/\/tmp$/) - }) - - it('kills the process group with SIGTERM when the signal fires', async () => { - // runBash owns no timer: it kills on abort. The executor drives the timeout - // by firing this signal via a deadline (see executor.spec.ts); here we - // assert the kill itself lands as SIGTERM. - const controller = new AbortController() - const start = Date.now() - const running = runBash(spec('sleep 60', { signal: controller.signal })) - setTimeout(() => { controller.abort('deadline') }, 100) - const result = await running.done - expect(Date.now() - start).toBeLessThan(5_000) - expect(result.signal).toBe('SIGTERM') - expect(result.exitCode).toBeNull() - }) - - it('escalates to SIGKILL when SIGTERM is trapped', async () => { - const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 })) - await waitForStdout(running, 'ready\n') - running.kill() - const result = await running.done - expect(result.signal).toBe('SIGKILL') - }) - - it('kills the whole process group (grandchildren die too)', async () => { - // The subshell writes the sleep's pid then waits on it; killing the - // group must take the sleep down with bash. - const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`) - const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`)) - const grandchild = await waitForPidFile(pidFile) - expect(grandchild).toBeGreaterThan(0) - - running.kill() - const result = await running.done - expect(result.signal).toBe('SIGTERM') - await waitGone(grandchild) - }) - - it('aborts via AbortSignal mid-run', async () => { - const controller = new AbortController() - const running = runBash(spec('sleep 60', { signal: controller.signal })) - setTimeout(() => { controller.abort('user cancelled') }, 50) - const result = await running.done - expect(result.signal).toBe('SIGTERM') - }) - - it('throws when the signal is already aborted before spawn', () => { - const controller = new AbortController() - controller.abort('too late') - expect(() => runBash(spec('echo hi', { signal: controller.signal }))) - .toThrow(/aborted before spawn: too late/) - }) - - it('rejects with a spawn error for a nonexistent cwd', async () => { - await expect(runBash(spec('echo hi', { cwd: '/nonexistent-dir-dsh-test' })).done) - .rejects.toThrow(/ENOENT/) - }) - - it('kill() is idempotent (second call does not restart escalation)', async () => { - const running = runBash(spec('sleep 60')) - running.kill() - running.kill() - const result = await running.done - expect(result.signal).toBe('SIGTERM') - }) - - it('bounds inherited-pipe draining after the shell exits', async () => { - const pidFile = join(spillDir, `pipe-holder-${Date.now()}.pid`) - const started = Date.now() - const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; echo shell-done`, { graceMs: 100 })) - const descendant = await waitForPidFile(pidFile) - try { - const result = await running.done - expect(Date.now() - started).toBeLessThan(1_000) - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('shell-done\n') - } finally { - process.kill(descendant, 'SIGKILL') - await waitGone(descendant) - } - }) -}) - -describe('stdin and extra env (set by in-process plugins)', () => { - it('writes stdin to the command and closes it', async () => { - const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('hello from stdin\n') - }) - - it('a command that reads stdin sees EOF when none is supplied', async () => { - // No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no - // output (it does NOT block). - const result = await runBash(spec('cat')).done - expect(result.exitCode).toBe(0) - expect(result.stdout.text).toBe('') - }) - - it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => { - // With no bytes, fd 0 remains the pre-seam `ignore` default (/dev/null, a character device). - // Supplied bytes use Node's spawn pipe, which is an AF_UNIX socket rather than a FIFO. - const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done - expect(none.stdout.text).toBe('char\n') - const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done - expect(piped.stdout.text).toBe('socket\n') - }) - - it('merges ordinary extra env entries onto the scrubbed environment', async () => { - const result = await runBash(spec('echo "$EXTRA_ONE/$EXTRA_TWO"', { - env: { EXTRA_ONE: 'alpha', EXTRA_TWO: 'beta' }, - })).done - expect(result.stdout.text).toBe('alpha/beta\n') - }) - - it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => { - // TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins. - // EXPLICIT_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit - // entry is still honored — the scrub only drops AMBIENT process.env creds. - const result = await runBash(spec('echo "$TERM/$EXPLICIT_OVERRIDE_KEY"', { - env: { TERM: 'xterm-256color', EXPLICIT_OVERRIDE_KEY: 'explicit-wins' }, - })).done - expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n') - }) - - it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => { - // The child exits without reading, so closing a stdin pipe holding ~1 MiB triggers EPIPE. - // The handler swallows that write error and `done` reports the child's real exit. - const big = 'x'.repeat(1024 * 1024) - const result = await runBash(spec('exit 7', { stdin: big })).done - expect(result.exitCode).toBe(7) - }) -}) - -describe('output truncation and spill', () => { - it('applies stdout and stderr caps independently', async () => { - const result = await runBash( - spec('printf "%.0sx" $(seq 1 500); printf "%.0se" $(seq 1 500) >&2', { - stdoutMaxBytes: 500, - stderrMaxBytes: 100, - }), - { spillDir }, - ).done - expect(result.stdout.truncated).toBe(false) - expect(result.stdout.text).toBe('x'.repeat(500)) - expect(result.stderr.truncated).toBe(true) - expect(result.stderr.text.length).toBeLessThanOrEqual(100) - }) - - it('keeps the tail and spills the full stream to disk', async () => { - // 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail. - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - expect(result.stdout.truncated).toBe(true) - expect(result.stdout.text.length).toBeLessThanOrEqual(500) - expect(result.stdout.text).toContain('line-0200') - expect(result.stdout.text).not.toContain('line-0001') - expect(result.stdout.spillPath).toBeDefined() - const full = readFileSync(result.stdout.spillPath!, 'utf8') - expect(full).toContain('line-0001') - expect(full).toContain('line-0200') - }) - - it('does not truncate output exactly at the cap', async () => { - const result = await runBash( - spec('printf "%.0sx" $(seq 1 500)', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - expect(result.stdout.truncated).toBe(false) - expect(result.stdout.text.length).toBe(500) - expect(result.stdout.spillPath).toBeUndefined() - }) - - it('settles with the tail and no spill path when final spill close fails', async () => { - failNextClose.value = true - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - expect(failNextClose.value).toBe(false) - expect(result.exitCode).toBe(0) - expect(result.stdout.truncated).toBe(true) - expect(result.stdout.text).toContain('line-0200') - expect(result.stdout.spillPath).toBeUndefined() - }) -}) - -describe('OutputCollector', () => { - it('keeps the tail of a single oversized chunk', () => { - const collector = new OutputCollector(10, 100, 'test', spillDir) - collector.push(Buffer.from('0123456789abcdef')) - const out = collector.finalize() - expect(out.text).toBe('6789abcdef') - expect(out.truncated).toBe(true) - expect(readFileSync(out.spillPath!, 'utf8')).toBe('0123456789abcdef') - }) - - it('readFrom returns increments and flags lossy reads', () => { - const collector = new OutputCollector(10, 100, 'test', spillDir) - collector.push(Buffer.from('aaaaa')) - const first = collector.readFrom(0) - expect(first.text).toBe('aaaaa') - expect(first.lossy).toBe(false) - expect(first.nextOffset).toBe(5) - - collector.push(Buffer.from('bbbbb')) - const second = collector.readFrom(first.nextOffset) - expect(second.text).toBe('bbbbb') - expect(second.lossy).toBe(false) - - // Push enough to slide the window past the last offset. - collector.push(Buffer.from('c'.repeat(20))) - const third = collector.readFrom(second.nextOffset) - expect(third.lossy).toBe(true) - expect(third.text).toBe('c'.repeat(10)) - expect(third.spillPath).toBeDefined() - }) - - it('contains close failures and drops the spill path', () => { - const collector = new OutputCollector(4, 100, 'closefail', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - expect(collector.readFrom(0).spillPath).toBeDefined() - - failNextClose.value = true - let out: ReturnType - expect(() => { out = collector.finalize() }).not.toThrow() - - expect(failNextClose.value).toBe(false) - expect(out!.text).toBe('bbbb') - expect(out!.truncated).toBe(true) - expect(out!.spillPath).toBeUndefined() - }) - - it('discards a spill that exceeds its configured cap', () => { - const collector = new OutputCollector(4, 8, 'bounded', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - const spillPath = collector.readFrom(0).spillPath! - expect(readFileSync(spillPath, 'utf8')).toBe('aaaabbbb') - - collector.push(Buffer.from('c')) - collector.push(Buffer.from('dddd')) - const out = collector.finalize() - expect(out.text).toBe('dddd') - expect(out.truncated).toBe(true) - expect(out.spillPath).toBeUndefined() - expect(() => readFileSync(spillPath)).toThrow() - }) - - it('does not create a spill when the first overflowing chunk exceeds the cap', () => { - const collector = new OutputCollector(4, 4, 'no-spill', spillDir) - collector.push(Buffer.from('abcdefgh')) - const out = collector.finalize() - expect(out.text).toBe('efgh') - expect(out.truncated).toBe(true) - expect(out.spillPath).toBeUndefined() - }) - - it('contains cleanup failures while disabling an oversize spill', () => { - const collector = new OutputCollector(4, 8, 'cleanup-fail', spillDir) - collector.push(Buffer.from('aaaa')) - collector.push(Buffer.from('bbbb')) - const spillPath = collector.readFrom(0).spillPath! - - failNextClose.value = true - failNextUnlink.value = true - expect(() => { collector.push(Buffer.from('c')) }).not.toThrow() - expect(failNextClose.value).toBe(false) - expect(failNextUnlink.value).toBe(false) - expect(collector.finalize().spillPath).toBeUndefined() - unlinkSync(spillPath) - }) -}) - -describe('killGroup', () => { - it('ignores non-positive pids', () => { - expect(() => { killGroup(-1, 'SIGTERM') }).not.toThrow() - expect(() => { killGroup(0, 'SIGTERM') }).not.toThrow() - }) - - it('swallows ESRCH for vanished groups', async () => { - const running = runBash(spec('true')) - await running.done - expect(() => { killGroup(running.pid, 'SIGTERM') }).not.toThrow() - }) -}) - -describe('abort edge cases', () => { - it('reports a fallback reason for reason-less pre-aborted signals', () => { - // Real AbortControllers always set a DOMException reason; signal-like - // objects from other libraries may not — the fallback covers them. - const bare = { - aborted: true, - reason: undefined, - addEventListener() {}, - removeEventListener() {}, - } as unknown as AbortSignal - expect(() => runBash(spec('echo hi', { signal: bare }))) - .toThrow(/aborted before spawn: aborted/) - }) - - it('reports the terminating signal of an externally self-killed command', async () => { - // runBash reports the raw signal; whether it counts as timeout/cancel is the - // executor's classification (a self-kill is neither) — see executor.spec.ts. - const result = await runBash(spec('kill -TERM $$')).done - expect(result.signal).toBe('SIGTERM') - }) -}) - -describe('environment and spill-file hardening', () => { - it('scrubs credential-shaped and ambient DSH env vars from child processes', async () => { - process.env.DSH_TEST_API_KEY = 'super-secret' - process.env.DSH_TEST_TOKEN = 'also-secret' - process.env.DSH_TEST_PLAIN = 'visible' - try { - const result = await runBash(spec('echo "[${DSH_TEST_API_KEY:-absent}|${DSH_TEST_TOKEN:-absent}|${DSH_TEST_PLAIN:-absent}]"')).done - expect(result.stdout.text.trim()).toBe('[absent|absent|absent]') - } finally { - delete process.env.DSH_TEST_API_KEY - delete process.env.DSH_TEST_TOKEN - delete process.env.DSH_TEST_PLAIN - } - }) - - it('injects only the current trusted DSH environment after scrubbing ambient values', async () => { - process.env.DSH_STALE = 'old-value' - try { - const result = await runBash(spec('echo "[${DSH_STALE:-absent}|$DSH_SHELL|$DSH_SESSION_ID]"', { - dshEnv: { DSH_SHELL: '1', DSH_SESSION_ID: 'current-session' }, - })).done - expect(result.stdout.text.trim()).toBe('[absent|1|current-session]') - } finally { - delete process.env.DSH_STALE - } - }) - - it('rejects DSH variables on the ordinary env channel', () => { - expect(() => runBash(spec('true', { env: { DSH_WRONG_CHANNEL: 'bad' } }))) - .toThrow(/DSH_WRONG_CHANNEL.*dshEnv/) - }) - - it('rejects ordinary variables on the managed env channel', () => { - const invalid = { PATH: '/wrong-channel' } as unknown as DshEnvironment - expect(() => runBash(spec('true', { dshEnv: invalid }))) - .toThrow(/managed bash env.*PATH.*use env/) - }) - - it('creates spill files with owner-only permissions and random names', async () => { - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - { spillDir }, - ).done - const path = result.stdout.spillPath! - expect(path).toMatch(/dsh-bash-\d+-\d+-[0-9a-f]{12}-stdout\.log$/) - const mode = statSync(path).mode & 0o777 - expect(mode).toBe(0o600) - }) - - it('defaults spills into a private per-process directory', async () => { - const result = await runBash( - spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { stdoutMaxBytes: 500, stderrMaxBytes: 500 }), - ).done - const dir = dirname(result.stdout.spillPath!) - expect(dir).toMatch(/dsh-bash-/) - const mode = statSync(dir).mode & 0o777 - expect(mode).toBe(0o700) - }) - - it('killGroup never throws, even for EPERM-style failures', () => { - const spy = vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error('EPERM'), { code: 'EPERM' }) - }) - try { - expect(() => { killGroup(12345, 'SIGTERM') }).not.toThrow() - } finally { - spy.mockRestore() - } - }) - - it('honors AbortSignal on background-style runs (no timeout)', async () => { - const controller = new AbortController() - const running = runBash(spec('sleep 60', { signal: controller.signal })) - setTimeout(() => { controller.abort() }, 50) - const result = await running.done - expect(result.signal).toBe('SIGTERM') - }) -}) diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index a55c76f00a..53ccc94926 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../bash/bash" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../support/invariants" } diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index 9faf613c71..0f2240630c 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index b889692f3c..3945809fa9 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -34,7 +34,7 @@ export type Config = LocalConfig * mode; `result.sandbox` reports the mode and enforcement actually used. */ export class SandboxBashExecutor extends LocalBashExecutor { - static inject = ['sandbox', 'sandboxPolicy'] + static override inject = ['subprocess', 'sandbox', 'sandboxPolicy'] // No own Config: the sandbox default (mode + workspaceRoot) moved to // ctx.sandboxPolicy, so this executor inherits LocalBashExecutor's Config @@ -128,7 +128,7 @@ export class SandboxBashExecutor extends LocalBashExecutor { * Wrap one shell command via the `ctx.sandbox` provider: hand over the * exact `['bash', '-c', command]` argv this executor would spawn, get back * the confined argv, and re-assemble it into the `exec …` command string - * the inherited spawn path runs (the outer `bash -c` that `runBash` spawns + * the inherited spawn path runs (the outer `bash -c` the subprocess service spawns * `exec`s into the runner, so no extra shell lingers). Provider errors * (fail-closed `SANDBOX_UNAVAILABLE`) propagate to the caller unchanged. */ diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 87bcffe9df..437078440c 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * Keyless integration of the real provider and executor through public run/start paths. With @@ -42,6 +43,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w ctx = new Context() await ctx.plugin(LocalSandboxProvider, {}) await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 3ce944b07c..0c5cfbe563 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -9,6 +9,7 @@ import { launcherPath } from 'node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * KEYLESS consumer-integration proof: the REAL `LocalSandboxProvider` (bwrap @@ -47,6 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 6e8f2229a0..90a67999c8 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -15,6 +15,7 @@ import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@ import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { classifyDenial, classifyRunnerFailure, shellQuote } from '../src/helpers.ts' import type { Config } from '@deepseek-ai/dsh-bash-sandbox' @@ -58,9 +59,10 @@ async function setup( ...mode !== undefined ? { mode } : {}, ...workspaceRoot !== undefined ? { workspaceRoot } : {}, }) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(SandboxBashExecutor, { graceMs: 200, ...execConfig }) const bash = ctx.bash as SandboxBashExecutor - bash.internals = { spillDir } return { ctx, bash, calls } } diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index 6c212ee546..62b1569ee7 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -9,6 +9,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' /** * Keyless macOS integration of the real provider and executor through public run/start paths. @@ -41,6 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w await ctx.plugin(LocalSandboxProvider, {}) ;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' } await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace }) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 }) return ctx.bash as SandboxBashExecutor } diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index f32efb60a0..520828acde 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: b4ee66a1fa2696254a1f2f411b7db5d3190f8370 -README.zh.md: 151d4bd7ab257234584b9008c96e6356d7e39351 +README.md: d7bf746969f52000fe298b65b995b7c631d8001c +README.zh.md: 14476b770397e4ef850c7c867e3058e25085c339 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index b4ee66a1fa..d7bf746969 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -33,7 +33,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md). -`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, reject those names in ordinary `env`, then merge `dshEnv`, so an omitted current fact cannot fall back to stale ambient state. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). ## Model Experience diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index 151d4bd7ab..14476b7703 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -33,7 +33,7 @@ 每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [core-data-structures/bash.md](../../../docs/core-data-structures/bash.md)。 -`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,拒绝普通 `env` 中的这些名称,再合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的单一真源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不公开任何一个字段。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 ## 模型体验 diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 93497df719..b8ff310f01 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -28,11 +28,13 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index d9eedee052..4f8ae112a9 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -43,7 +43,10 @@ declare module 'cordis' { * failures settle as `killed` with the error on stderr. * - {@link BashProcess.readOutput} is incremental: consecutive reads never * repeat output. Lossy reads report truncation and available spill files. - * - Disposal kills all running background processes and awaits their exit. + * - A still-running background process is stopped and awaited when its + * owning composition tears down. With the subprocess seam that + * boundary is `ctx.subprocess` disposal, so a background process survives + * an executor-only reload. */ export abstract class BashExecutor extends Service { constructor(ctx: Context) { diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index a504513417..6fdd8b36c7 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -1,19 +1,17 @@ /** * Execution types for the bash executor seam. Background task semantics belong - * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. + * to `@deepseek-ai/dsh-tasks`; this seam exposes only process handles. The + * managed-environment and captured-output vocabulary is owned by the + * subprocess seam and re-exported here so bash consumers keep one import + * root. * @module dsh-bash/types */ import type { SandboxEnforcement, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { CollectedOutput, DshEnvironment } from '@deepseek-ai/dsh-subprocess' -/** Namespace prefix reserved for DeepSeek Harness-managed child environment facts. */ -export const DSH_ENV_PREFIX = 'DSH_' as const - -/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ -export type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` - -/** Trusted DeepSeek Harness variables for one bash execution. */ -export type DshEnvironment = Readonly> +export { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-subprocess' +export type { CollectedOutput, DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-subprocess' /** * Sandbox facts for one run, present iff a sandboxing executor handled it. @@ -62,17 +60,18 @@ export interface BashExecRequest { stdin?: string | undefined /** * Ordinary environment entries for the command, merged after the credential - * scrub. `DSH_*` is reserved for {@link dshEnv} and implementations reject it - * here. Set by in-process plugins (the hooks bridges set - * `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing bash tool - * does not expose it as a parameter. + * scrub. Managed facts belong in {@link dshEnv}, which merges after this + * map, so an entry here can never displace one. Set by in-process plugins + * (the hooks bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the + * model-facing bash tool does not expose it as a parameter. */ env?: Record | undefined /** - * Harness-owned `DSH_*` variables for this execution. Executors discard - * ambient `DSH_*` entries before merging this snapshot, so an unavailable - * current fact cannot inherit a stale value from the harness process, and - * reject non-`DSH_*` names supplied through this managed channel. + * Harness-owned `DSH_*` variables for this execution (typed to managed + * keys). Executors discard ambient `DSH_*` entries before merging this + * snapshot last, so an unavailable current fact cannot inherit a stale + * value from the harness process and a caller {@link env} entry cannot + * displace a managed one. */ dshEnv?: DshEnvironment | undefined /** Fully resolved per-call sandbox policy; sandboxing executors default it. */ @@ -99,27 +98,17 @@ export interface BashExecSpec { stdin?: string | undefined /** * Ordinary environment entries carried through from - * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * {@link BashExecRequest.env}; {@link dshEnv} still merges after them. * OPTIONAL on the spec for the same reason as `stdin`: absent means no * ordinary extra environment. */ env?: Record | undefined - /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ + /** Managed `DSH_*` snapshot (typed to managed keys); merges after {@link env}. */ dshEnv?: DshEnvironment | undefined /** Resolved sandbox policy; ignored by executors that do not confine. */ sandboxPolicy: SandboxExecutionPolicy | undefined } -/** One captured stream: the (possibly truncated) text plus recovery info. */ -export interface CollectedOutput { - /** Collected text — the TAIL of the stream when truncated. */ - text: string - /** True when bytes were dropped from `text`. */ - truncated: boolean - /** Path to a file holding the COMPLETE stream, when truncated and available. */ - spillPath?: string -} - /** The outcome of one completed (or killed) foreground run. */ export interface BashRunResult { /** Exit code; null when the process died from a signal. */ @@ -165,8 +154,9 @@ export interface BashProcessRead { /** * A background process handle returned by {@link BashExecutor.start}. It is the - * only access path; buffered output remains readable after exit. Executor - * disposal kills running processes and awaits {@link done}. + * only access path; buffered output remains readable after exit. Composition + * teardown (the subprocess service's disposal) kills running processes and + * awaits {@link done}; an executor-only reload leaves them running. */ export interface BashProcess { /** Process lifecycle state (settled exactly once). */ diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 51175e6080..3f611c80e0 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../sandbox/sandbox" }, diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index a89e147e0e..c34e1c6e7b 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index e1315c232a..fcfaae08d4 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import LocalTaskService from '@deepseek-ai/dsh-tasks-local' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -29,6 +30,7 @@ async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: str await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(ToolBash, dshHome === undefined ? {} : { dshHome }) ctx.llm.registerAdapter(['mock'], adapter) @@ -109,11 +111,12 @@ describe('bash tool through the agent loop', () => { const location = ctx.sessionPersistence.locate(agent.session.header) expect(location?.kind).toBe('jsonl') - agent.followup([{ type: 'text', text: 'inspect the current session' }]) + agent.followup({ content: [{ type: 'text', text: 'inspect the current session' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const result = findEvent(events(agent), 'tool/result') expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`) + await ctx.sessions.flush(agent.session) expect(existsSync(location!.path)).toBe(true) const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string } expect(header).toMatchObject({ type: 'session', id: 'session-env-id' }) @@ -128,7 +131,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-fg'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'run echo integration-ok' }]) + agent.followup({ content: [{ type: 'text', text: 'run echo integration-ok' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const log = events(agent) @@ -160,7 +163,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-exit'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'run exit 9' }]) + agent.followup({ content: [{ type: 'text', text: 'run exit 9' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const toolResult = findEvent(events(agent), 'tool/result') @@ -180,7 +183,7 @@ describe('bash tool through the agent loop', () => { const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('it-bg'), { provider: 'mock', model: 'mock' }) - agent.followup([{ type: 'text', text: 'run echo bg-ok in the background' }]) + agent.followup({ content: [{ type: 'text', text: 'run echo bg-ok in the background' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const firstResult = findEvent(events(agent), 'tool/result') @@ -200,7 +203,7 @@ describe('bash tool through the agent loop', () => { expect(notice.data.source).toEqual({ kind: 'plugin', plugin: 'tool-tasks' }) // The next turn collects the output through the generic task tool. - agent.followup([{ type: 'text', text: 'collect it' }]) + agent.followup({ content: [{ type: 'text', text: 'collect it' }], source: { kind: 'user' } }) await waitForIdle(ctx, agent) const readResult = findEvent(events(agent), 'tool/result', 'last') expect(readResult.data.isError).toBe(false) diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 80840fbf75..15222ee647 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -17,6 +17,7 @@ import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import ApprovalService from '@deepseek-ai/dsh-user-approval' import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import { processOutcome } from '../src/background.ts' @@ -32,8 +33,9 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -46,8 +48,9 @@ async function setupWithTasks() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalTaskService) await ctx.plugin(ToolTasks) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -275,8 +278,9 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) + ;(ctx.subprocess as LocalSubprocessService).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -383,6 +387,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) const fiber = await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(1) @@ -400,6 +405,7 @@ describe('bash tool', () => { // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. await ctx.plugin(ToolBash) expect(ctx.tools.schemas()).toHaveLength(0) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) await new Promise(resolve => setTimeout(resolve, 0)) expect(ctx.tools.schemas()).toHaveLength(1) @@ -411,6 +417,7 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) ToolBash.apply(ctx, {}) const schema = ctx.tools.schemas()[0]! @@ -526,6 +533,7 @@ describe('background execution through the task runtime', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalSubprocessService) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(ToolBash, { enableRunInBackground: false }) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index f5526a81e0..3728641560 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -75,7 +75,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy): 1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. -2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; the `CLIENT_PACKAGES` roster in `apps/cli/src/web.ts`; an `apps/cli/package.json` dependency (`mountWebPlugins` resolves roster packages against the composing app's URL — a roster row that is not a dependency of `apps/cli` fails to mount). `pnpm-workspace.yaml` already globs `packages/*/*`. +2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. 4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case). 5. Rebuild the bundle (`pnpm --filter bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index c3cd40ea9d..0bac9f0bed 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -10,6 +10,8 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, + ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelReasoningEffort, ModelTarget, SessionModels, } 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 d0ffa2fe49..17cacd9dc8 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -7,10 +7,10 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, - RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, + ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -47,6 +47,25 @@ const MARKDOWN_FIXTURE = [ const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)' +const DEEPSEEK_REASONING = { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max' }, + ], + defaultEffort: 'high', +} + +const OPENAI_REASONING = { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'medium', name: 'Medium' }, + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max' }, + ], + defaultEffort: 'medium', +} + function sid(id: string): SessionId { return id as SessionId } @@ -178,7 +197,22 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - push({ type: 'turn/start', data: { turn: 65, trigger: { kind: 'message', source: { kind: 'user' } } } }) + // Turn 65: todo_write sample — the TodoRow toolview in the flow plus the + // todo/write snapshot event feeding the TodoPanel plan strip. + const fixtureTodos = [ + { content: '梳理需求', status: 'completed' }, + { content: '实现 fixture 样本', status: 'in_progress' }, + { content: '浏览器验收', status: 'pending' }, + ] + const todoArgs = JSON.stringify({ todos: fixtureTodos }) + toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + // The real tool appends the snapshot mid-execution — between tool/call and + // tool/result — so the fixture reproduces that exact ordering (the last + // toolTurn events run ... tool/call, tool/result, step/end, turn/end). + const callIndex = events.length - 4 + const callTime = events[callIndex]?.time as number + events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } }) + push({ type: 'turn/start', data: { turn: 66, trigger: { kind: 'message', source: { kind: 'user' } } } }) push({ type: 'user/message', surfaceOp: 'append', @@ -187,19 +221,20 @@ function buildAlphaLog(): SessionEvent[] { source: { kind: 'user' }, }, }) - push({ type: 'step/start', data: { turn: 65, step: 0 } }) + push({ type: 'step/start', data: { turn: 66, step: 0 } }) push({ type: 'assistant/message', surfaceOp: 'append', data: { - turn: 65, + turn: 66, step: 0, content: [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], provenance: { provider: 'fixture', model: 'fx-vision' }, }, }) - push({ type: 'step/end', data: { turn: 65, step: 0 } }) - push({ type: 'turn/end', data: { turn: 65, reason: { kind: 'completed' } } }) + push({ type: 'step/end', data: { turn: 66, step: 0 } }) + push({ type: 'turn/end', data: { turn: 66, reason: { kind: 'completed' } } }) + events.forEach((e, i) => { e.seq = i }) return events as unknown as SessionEvent[] } @@ -326,6 +361,15 @@ function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: str return log.some(event => visit(event.data)) } +/** Current todo projection over the full log (host parallel: latest todo/write, last write wins). */ +function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined { + for (let i = log.length - 1; i >= 0; i--) { + const event = log[i] + if (event !== undefined && event.type === 'todo/write') return event.data.todos + } + return undefined +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -399,7 +443,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, ] const logs = new Map([[sid('fx-alpha'), buildAlphaLog()]]) - const nextTurn = new Map([[sid('fx-alpha'), 66]]) + const modelTargets = new Map(sessions.map(session => [ + session.sessionId, + { provider: 'deepseek', model: 'deepseek-v4-flash' }, + ])) + const nextTurn = new Map([[sid('fx-alpha'), 67]]) const attachments = new Map([[ String(FIXTURE_IMAGE_REF.attachmentId), { attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA }, @@ -645,6 +693,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, } sessions.push(created) + modelTargets.set(created.sessionId, { provider: 'deepseek', model: 'deepseek-v4-flash' }) attachedSessions += 1 const emitSession = (): void => { // Mirrors the host: the frame fires at creation, so blank is constantly true. @@ -668,12 +717,55 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { const log = logs.get(request.payload.sessionId) ?? [] // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency). const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50) + // Tail page carries the session-level todo projection (host parallel: full-log backscan). + const todos = request.payload.beforeSeq === undefined ? backscanTodos(log) : undefined const doomed = failNextHistory failNextHistory = false const delay = historyDelayMs if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) if (doomed) throw new Error('fixture: simulated history transport failure') - return ok(request, page) + return ok(request, { ...page, ...todos === undefined ? {} : { todos } }) + }, + models: request => ok(request, { + current: modelTargets.get(request.payload.sessionId) + ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }, + groups: [ + { + id: 'deepseek', + name: 'DeepSeek', + models: [ + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + description: '快速响应', + reasoning: DEEPSEEK_REASONING, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + description: '复杂任务', + reasoning: DEEPSEEK_REASONING, + }, + ], + }, + { + id: 'openai', + name: 'OpenAI', + models: [{ id: 'gpt-5', name: 'GPT-5', reasoning: OPENAI_REASONING }], + }, + ], + failures: [], + }), + selectModel: (request) => { + const selected: ModelTarget = { + provider: request.payload.provider, + model: request.payload.model, + ...request.payload.reasoningEffort === undefined + ? {} + : { reasoningEffort: request.payload.reasoningEffort }, + } + modelTargets.set(request.payload.sessionId, selected) + return ok(request, { selected }) }, prompt: (request) => { const { sessionId: id, mode, content } = request.payload @@ -737,7 +829,13 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { turn, userText === 'render markdown' ? MARKDOWN_FIXTURE - : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, + : userText === 'report model' + ? (() => { + const target = modelTargets.get(id) + return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}` + + (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`) + })() + : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, ) return ok(request, { accepted: true as const }) }, @@ -795,6 +893,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, attachedSessions, }), + pickDirectory: request => ok(request, { path: null }), }, workspace: { list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }), @@ -841,6 +940,20 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { } return ok(request, { workspace: { ...workspace } }) }, + delete: (request) => { + const { workspaceId } = request.payload + const index = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) + if (index === -1) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + workspaces.splice(index, 1) + emitHost({ type: 'host/workspace-removed', workspaceId }) + return ok(request, { deleted: true as const }) + }, insertSessionBefore: (request) => { const { workspaceId, sessionId, beforeSessionId } = request.payload const workspace = workspaces.find(w => w.workspaceId === workspaceId) @@ -1026,13 +1139,17 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.list': return this.api.sessions.list(request) case 'session.create': return this.api.sessions.create(request) case 'session.history': return this.api.sessions.history(request) + case 'session.models': return this.api.sessions.models(request) + case 'session.selectModel': return this.api.sessions.selectModel(request) case 'session.prompt': return this.api.sessions.prompt(request) case 'session.attachment': return this.api.sessions.attachment(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) + case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) case 'workspace.rename': return this.api.workspace.rename(request) + case 'workspace.delete': return this.api.workspace.delete(request) case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) case 'command.list': return this.api.commands.list(request) // The in-memory execute never blocks, so a never-aborting signal is faithful here. diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 1499782edb..644949ff9f 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -15,6 +15,8 @@ export type { ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry, + ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelReasoningEffort, ModelTarget, SessionModels, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 98e56778b6..28ef97843e 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -6,6 +6,7 @@ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge } from './http-bridge.ts' +import { isTrustedNativeDialogRequest } from './native-dialog-request.ts' export { API_PATH } from './api-path.ts' @@ -30,7 +31,16 @@ export function apply(ctx: Context): void { const route: WebRoute = { kind: 'prefix', path: API_PATH, - handler: (req, res) => bridge(req, res, apiHandler, maxRequestBodyBytes), + handler: async (req, res) => { + const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname + if (pathname === `${API_PATH}/host.pickDirectory` + && !isTrustedNativeDialogRequest(req)) { + res.writeHead(403) + res.end('forbidden') + return + } + await bridge(req, res, apiHandler, maxRequestBodyBytes) + }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') } diff --git a/packages/client/connection/src/native-dialog-request.ts b/packages/client/connection/src/native-dialog-request.ts new file mode 100644 index 0000000000..fe91bbae2d --- /dev/null +++ b/packages/client/connection/src/native-dialog-request.ts @@ -0,0 +1,52 @@ +/** Trust check for browser requests that can open an operating-system dialog. */ + +import type { IncomingHttpHeaders } from 'node:http' + +interface NativeDialogRequest { + headers: IncomingHttpHeaders + socket: { remoteAddress?: string | undefined } +} + +function header(headers: IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name] + return typeof value === 'string' ? value : undefined +} + +function isLoopback(address: string | undefined): boolean { + if (address === undefined) return false + if (address === '::1') return true + const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address + const first = ipv4.split('.')[0] + return first === '127' +} + +function isLoopbackHostname(hostname: string): boolean { + if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true + const parts = hostname.split('.') + return parts.length === 4 + && parts[0] === '127' + && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) +} + +/** + * Require a local socket plus browser-controlled same-origin metadata. + * @param request - the node HTTP request facts used by the carrier guard. + * @returns true only for a same-origin browser request whose peer and URL are loopback. + */ +export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean { + if (!isLoopback(request.socket.remoteAddress)) return false + if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false + const origin = header(request.headers, 'origin') + const host = header(request.headers, 'host') + if (origin === undefined || host === undefined) return false + try { + const parsed = new URL(origin) + const hostUrl = new URL(`http://${host}`) + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') + && parsed.host === host + && isLoopbackHostname(parsed.hostname) + && isLoopbackHostname(hostUrl.hostname) + } catch { + return false + } +} diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 307cd0a2c0..bcd5240917 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -2,8 +2,8 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, - RpcRequest, RpcResponse, SessionId, SkillEntry, + CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame, + RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry, } from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' @@ -45,15 +45,29 @@ export class FakeApiClient implements IApiClient { onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = - () => Promise.resolve(ok({ events: [], hasMore: false })) + => Promise> = + () => Promise.resolve(ok({ + events: [], + hasMore: false, + modelTarget: { provider: 'deepseek', model: 'deepseek-chat' }, + })) + onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ + current: { provider: 'deepseek', model: 'deepseek-chat' }, + groups: [], + failures: [], + })) + onSelectModel: (payload: ModelTarget & { sessionId: SessionId }) + => Promise> = + payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onAttachment: (payload: unknown) => Promise> = () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -66,6 +80,9 @@ export class FakeApiClient implements IApiClient { create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)), history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), + models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)), + selectModel: (payload: ModelTarget & { sessionId: SessionId }) => + this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), @@ -73,6 +90,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), + pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), } readonly workspace: IApiClient['workspace'] = { @@ -84,6 +102,7 @@ export class FakeApiClient implements IApiClient { rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), + delete: (payload: unknown) => this.record('workspace.delete', payload, Promise.resolve(ok({ deleted: true as const }))), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, }))), @@ -91,12 +110,10 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program catalogs and skill lists without casts. - onCommandList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ matched: false })) - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) + onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) + onCommandExecute: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ matched: false })) + onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 0998fabd36..0e5f241266 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -68,7 +68,56 @@ describe('createFixtureApi', () => { // Unknown session: empty page, not an error (history of a bare id). const empty = await api.sessions.history(req({ sessionId: sid('no-such'), maxMessages: 10 })) if (!empty.result.ok) throw new Error('empty failed') - expect(empty.result.value).toEqual({ events: [], hasMore: false }) + expect(empty.result.value).toEqual({ + events: [], + hasMore: false, + }) + }) + + it('serves grouped models and keeps a selected target for later history and fixture requests', async () => { + const api = createFixtureApi() + const sessionId = sid('fx-alpha') + const catalog = await api.sessions.models(req({ sessionId })) + if (!catalog.result.ok) throw new Error('models failed') + expect(catalog.result.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI']) + expect(catalog.result.value.groups[0]?.models.map(model => model.id)) + .toEqual(['deepseek-v4-flash', 'deepseek-v4-pro']) + + const selected = await api.sessions.selectModel(req({ + sessionId, + provider: 'openai', + model: 'gpt-5', + })) + if (!selected.result.ok) throw new Error('selection failed') + expect(selected.result.value.selected).toEqual({ provider: 'openai', model: 'gpt-5' }) + const history = await api.sessions.history(req({ sessionId })) + if (!history.result.ok) throw new Error('history failed') + + const prompt = await api.sessions.prompt(req({ + sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'report model' }], + })) + expect(prompt.result.ok).toBe(true) + await new Promise(resolve => setTimeout(resolve, 600)) + const after = await api.sessions.history(req({ sessionId })) + if (!after.result.ok) throw new Error('history failed') + expect(JSON.stringify(after.result.value.events)).toContain('openai/gpt-5') + }) + + it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => { + const api = createFixtureApi() + const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) + if (!tail.result.ok) throw new Error('history failed') + const events = tail.result.value.events.map(e => e.event) + const todoAt = events.findIndex(e => e.type === 'todo/write') + expect(todoAt).toBeGreaterThan(0) + // Production ordering (the tool appends mid-execution): call → snapshot → result. + expect(events[todoAt - 1]?.type).toBe('tool/call') + expect(events[todoAt + 1]?.type).toBe('tool/result') + const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time) + expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0) + expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0) }) it('create adds a session and pushes host/session-added to open host streams', async () => { @@ -412,6 +461,31 @@ describe('createFixtureApi', () => { expect(noop.result.value.workspace.updatedAt).toBe(before) }) + it('workspace.delete removes only the Workspace row and emits the removal frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const missing = await api.workspace.delete(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + const deleted = await api.workspace.delete(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId })) + expect(deleted.result).toEqual({ ok: true, value: { deleted: true } }) + await consuming + expect(seen).toEqual([{ type: 'host/workspace-removed', workspaceId: 'fx-ws-fixture' }]) + const list = await api.workspace.list(req({})) + if (!list.result.ok) throw new Error('workspace list failed') + expect(list.result.value.items.some(workspace => workspace.workspaceId === 'fx-ws-fixture')).toBe(false) + const sessions = await api.sessions.list(req({})) + if (!sessions.result.ok) throw new Error('session list failed') + expect(sessions.result.value.items.map(session => session.sessionId)).toContain('fx-alpha') + }) + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() const abort = new AbortController() diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts new file mode 100644 index 0000000000..608630cf15 --- /dev/null +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -0,0 +1,47 @@ +import { EventEmitter } from 'node:events' +import { Readable } from 'node:stream' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { describe, expect, it } from 'vitest' +import { bridge } from '../src/http-bridge.ts' + +describe('HTTP bridge abort', () => { + it('aborts a pending native picker request when the browser disconnects', async () => { + const body = JSON.stringify({ + type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {}, + }) + const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/host.pickDirectory', + method: 'POST', + headers: { 'content-type': 'application/json' }, + }) + + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead() { return this }, + write() { return true }, + end() { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + let resolveStarted!: () => void + const started = new Promise((resolve) => { resolveStarted = resolve }) + let carrierSignal: AbortSignal | undefined + const pending = bridge(request, response, { + fetch: async (input) => { + const fetchRequest = input as Request + carrierSignal = fetchRequest.signal + resolveStarted() + if (!fetchRequest.signal.aborted) { + await new Promise((resolve) => { + fetchRequest.signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + return Response.json({ aborted: fetchRequest.signal.aborted }) + }, + }, Number.MAX_SAFE_INTEGER) + await started + response.emit('close') + await pending + expect(carrierSignal?.aborted).toBe(true) + }) +}) diff --git a/packages/client/connection/tests/native-dialog-request.spec.ts b/packages/client/connection/tests/native-dialog-request.spec.ts new file mode 100644 index 0000000000..1a3d70dd15 --- /dev/null +++ b/packages/client/connection/tests/native-dialog-request.spec.ts @@ -0,0 +1,57 @@ +import type { IncomingHttpHeaders } from 'node:http' +import { describe, expect, it } from 'vitest' +import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts' + +function request( + remoteAddress: string | undefined, + headers: IncomingHttpHeaders = { + host: '127.0.0.1:3080', + origin: 'http://127.0.0.1:3080', + 'sec-fetch-site': 'same-origin', + }, +) { + return { socket: { remoteAddress }, headers } +} + +describe('native dialog request trust', () => { + it('accepts loopback same-origin browser requests', () => { + expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true) + expect(isTrustedNativeDialogRequest(request('::1', { + host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + expect(isTrustedNativeDialogRequest(request('127.0.0.2', { + host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(true) + }) + + it('rejects remote sockets and requests without matching browser metadata', () => { + expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false) + expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + expect(isTrustedNativeDialogRequest(request('127.0.0.1', { + host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin', + }))).toBe(false) + }) +}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index e0670825b2..aca2cc5398 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,6 +1,7 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' +import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' @@ -31,6 +32,23 @@ describe('connection node half', () => { expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + let status: number | undefined + let body: unknown + const deniedRequest = { + url: '/api/host.pickDirectory', + headers: { + host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', + }, + socket: { remoteAddress: '192.168.1.8' }, + } as unknown as IncomingMessage + const deniedResponse = { + writeHead(value: number) { status = value; return this }, + end(value?: unknown) { body = value; return this }, + } as unknown as ServerResponse + await routes[0]!.handler(deniedRequest, deniedResponse) + expect(status).toBe(403) + expect(body).toBe('forbidden') + await fiber.dispose() expect(routes).toHaveLength(0) }) diff --git a/packages/client/hmr/src/client/index.ts b/packages/client/hmr/src/client/index.ts index eae29e8db3..f1d3859ccb 100644 --- a/packages/client/hmr/src/client/index.ts +++ b/packages/client/hmr/src/client/index.ts @@ -5,7 +5,7 @@ * `rebuilt` frame it re-fetches the entry's bundle and swaps the cordis * fiber in place. Every graph entry is a plugin bundle under the web2 model * — `immediately` rows differ only in stage-one prefetch (a boot - * optimization), so all nine plugin packages share these reload semantics; + * optimization), so all rostered plugin packages share these reload semantics; * normal packages (react family, cordis, shell, pure libs) are not entries * and shell changes still mean a page reload. Cascade is zero-touch: * downstream fibers key their activation epoch on provider fiber uids diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index df0472c466..c690eef5dc 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: c9881e809e2249301855244e7969aa8fb562a5a2 -README.zh.md: db81108d9ed34cace68726b1a86349da46ec1c70 +README.md: f2452869bd1b92ad84cd0fd4a015656391f8a11f +README.zh.md: 836cba96d016f1b446e177e4501cd3f9f0c426d3 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index c9881e809e..f2452869bd 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,11 +2,13 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and the latest successful host capability description; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state, and the latest successful host capability description; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session's current todo projection: taken from the tail history page's full-log value (host-computed, independent of the page window), preserved across an older-page prepend, and overwritten by each live `todo/write` (last write wins). A tail response that omits the field means the log holds no `todo/write`, so the list resets to empty — a plan the log never kept (a write lost to a host crash) disappears on the next open or resync. ## Workspace and Session lists -Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental frames arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. +Workspace and Session lists have independent monotone `pending` → `ready` baseline phases and separate refresh activity/error state. Incremental upsert/removal frames and unary mutation echoes arriving during a list request replay over its response. The first successful baseline establishes Host order; later refreshes update rows and membership without changing the relative order of identities already shown. Removed Workspace ids retain process-local tombstones so late changed frames cannot resurrect them; reconnect still takes `workspace.list` as the baseline. Workspace recency is derived only after both baselines are ready and never changes Workspace list order. + +`WorkspacesService.delete(workspaceId)` removes the registration from the client projection after the successful unary response; the matching `host/workspace-removed` frame is idempotent and synchronizes other tabs. Session state and the current Session selection are independent, so accounted Sessions immediately project under Ungrouped after their Workspace disappears. SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store. @@ -22,13 +24,17 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. +## Session model selection + +Each resident `Session` owns a `modelSelection` snapshot containing the current provider/model target, provider-grouped directory, provider-local failures, and the `idle`/`loading`/`ready`/`selecting`/`error` state. History establishes or refreshes the current target, opening a selector refreshes the directory, and selection failures preserve the last target and usable groups. Directory and selection operations share a monotonically increasing generation so an older response cannot overwrite a newer selection. A reconnect rebuild restores the target reported by the Host without replacing unchanged selection substructure. + ## Model Experience -None, as the client runtime hosts browser-side services and the session object layer; nothing here reaches a model request. +None, as the session object layer selects the provider/model route used by a later Host request but adds no model-visible content. #### KV Cache effect -None; this package neither assembles nor sends a provider request. +Changing the target can change or invalidate provider-side cache reuse; this package does not alter the prompt prefix itself. ## Known Limitations and Deferred Work diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index db81108d9e..836cba96d0 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,11 +2,13 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态,以及最新一次成功的宿主能力描述;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表/scope/history 状态,以及最新一次成功的宿主能力描述;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd);客户端不持有任何实体化之前的会话状态——Agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约:api-contracts v3 §4。`ConversationSnapshot` 携带 `todos`——会话当前的 todo 投影:取自尾页 history 携带的全量 log 值(host 计算,独立于分页窗口),跨往前翻页保留,并被每次实时 `todo/write` 覆盖(后写胜出)。尾页响应省略该字段即表示 log 中没有任何 `todo/write`,因此列表复位为空——log 从未留下的计划(写入因 host 崩溃丢失)会在下一次打开或 resync 时消失。 ## Workspace 与 Session 列表 -Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量帧会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 +Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线阶段,也有各自的刷新活动/错误状态。列表请求期间到达的增量更新/移除帧与一元变更回显会在其响应之上回放。第一次成功的基线建立 Host 顺序;后续刷新更新行和成员关系,但不改变已经显示的标识之间的相对顺序。已移除的 Workspace id 会保留进程本地删除标记,避免延迟到达的 changed 帧将其复活;重连仍以 `workspace.list` 作为基线。Workspace 新近程度只在两条基线都 ready 后派生,且绝不改变 Workspace 列表顺序。 + +`WorkspacesService.delete(workspaceId)` 在一元响应成功后从客户端投影中移除注册记录;对应的 `host/workspace-removed` 帧具有幂等性,并负责同步其他标签页。Session 状态与当前 Session selection 相互独立,因此 Workspace 消失后,其已记账的 Session 会立即投影到 Ungrouped 下。 SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建 hook。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。 @@ -22,13 +24,17 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。 +## 会话模型选择 + +每个常驻 `Session` 都拥有一个 `modelSelection` 快照,其中包含当前提供方/模型目标、按提供方分组的目录、逐提供方失败记录,以及 `idle`/`loading`/`ready`/`selecting`/`error` 状态。历史记录会建立或刷新当前目标,打开选择器会刷新目录;选择失败会保留上一个目标和可用分组。目录与选择操作共用单调递增的代次,因此较旧响应无法覆盖较新的选择。重连重建会恢复 Host 报告的目标,同时不替换未变化的选择子结构。 + ## 模型体验 -无。客户端运行时承载浏览器侧服务与 Session 对象层;这里没有任何内容进入模型请求。 +无,因为 Session 对象层会选择后续 Host 请求使用的提供方/模型路由,但不添加任何模型可见内容。 #### KV Cache 影响 -无;该包既不组装也不发送提供方请求。 +更改目标可能改变提供方侧的缓存复用,或使其失效;该包本身不会改变提示词前缀。 ## 已知限制与暂缓事项 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index a1f9c6bd47..0348fc5f98 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -13,7 +13,7 @@ export type { RootOwnerProps } from './slots.ts' export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts' export { createScope } from './agents/scope.ts' export type { AgentScopeHandle } from './agents/scope.ts' -export { WorkspacesService } from './workspaces/service.ts' +export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts' export type { Session } from './sessions/session.ts' export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, @@ -30,7 +30,7 @@ export type { export type { AssistantBlock, AssistantMessageNode, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, QueuedMessage, RunningToolCall, - SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, + SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { PendingWait } from './sessions/pending.ts' export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 285b461329..e98eaa72da 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -5,11 +5,14 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' +export type { TodoItem } + /** Assistant content blocks sorted by what the UI cares about * (text body / collapsible reasoning / tool-call card head / other fallback). */ export type AssistantBlock = @@ -91,7 +94,6 @@ export interface ContextMessageNode { time: number content: readonly ContentBlock[] source: unknown - meta?: unknown } /** A tool result paired (when in-window) with its call head. */ @@ -248,4 +250,7 @@ export interface ConversationSnapshot { */ blank: boolean lastAgentError: string | null + /** Current whole-list `todo/write` projection — the tail page's full-log value, then each live + * write (last write wins); empty = the log holds no plan. */ + todos: readonly TodoItem[] } diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index 0f40d9bf2a..d72c1af8e3 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -46,7 +46,6 @@ function materializeNode( return { kind: 'context', seq: event.seq, time: event.time, content: event.data.content, source: event.data.source, - meta: event.data.meta, } } return { diff --git a/packages/client/runtime/src/client/sessions/service.ts.orig b/packages/client/runtime/src/client/sessions/service.ts.orig deleted file mode 100644 index deb1616a8a..0000000000 --- a/packages/client/runtime/src/client/sessions/service.ts.orig +++ /dev/null @@ -1,590 +0,0 @@ -/** - * SessionsService: root sessions service — list snapshot store (manager - * projection; carries `current`, the persisted selection every - * session-scoped surface keys off — migrated here from ui-layout per the - * slot-parity design), Agent scope tree (mintScope pattern: no-op plugin - * Fiber + ctx.extend scope tag; one scope per session, agent id === session - * id), stable SessionBinding cache, ancestry walk. - * - * Scope lifecycle is stage-driven: a scope is minted lazily on first - * resolution (pure — resolution has no side effects and is render-safe); - * the event window and deferred teardown key off the STAGED session, which - * follows `list.current` exactly. Staging is the open signal: the window - * opens ⟺ the session is on stage (today the stage is `current`; the staged - * state can widen to a multi-pane list later). A session leaving the list - * tears its scope down immediately unless it is the staged one, whose scope - * survives frozen (read-only view) until the stage moves on. - */ -import type { Context, Fiber } from 'cordis' -import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client' -import type { - HostObservable, SessionMaybeProvideInfo, SessionProvideInfo, -} from '@deepseek-ai/dsh-client-ui-slots' -import type { SnapshotStore } from '../contract/store.ts' -import { createSnapshotStore } from '../contract/store.ts' -import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts' -import { SessionManager } from './manager.ts' -import type { SessionListPhase } from './manager.ts' -import type { Session } from './session.ts' - -/** Session list row projected from the host list RPC plus live stream increments. */ -export interface SessionSummary { - id: SessionId - /** Latest durable log-backed title, absent until the host projects one. */ - title?: string - /** Human-facing label: durable title, project basename, then session id. */ - displayTitle: string - cwd?: string - parentId?: SessionId - running: boolean - /** - * Empty-log bit (host summary derivation mirror). List surfaces hide blank - * sessions; New Session reuses a blank one targeting the same workspace. - * Filtering stays with the consumer — the store carries every row. - */ - blank: boolean - updatedAt: number -} - -/** - * Session list store shape. `current` rides the same snapshot (arbitrated: - * the single useSessions standard hook reads list and selection together — - * sidebar highlighting and SessionProvider share one fact source). - */ -export interface SessionListState { - ids: SessionId[] - byId: Record - current: SessionId | undefined - /** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */ - phase: SessionListPhase -} - -/** Structured session-create failure. */ -export class SessionCreateError extends Error { - override readonly name = 'SessionCreateError' - - /** - * @param rpcError - Host business or folded transport error. - * @param requestedSessionId - caller-preallocated id used for later stream/list reconciliation. - */ - constructor( - readonly rpcError: RpcError, - readonly requestedSessionId: SessionId | undefined, - ) { - super(`session create failed: ${rpcError.code}: ${rpcError.message}`) - } -} - -/** Session assembly handle for SessionProvider/inject factories (identity-stable per session). */ -export interface SessionBinding { - readonly sessionId: SessionId - readonly session: Session - readonly ctx: Context -} - -// Scope primitives live in ../agents/scope.ts (the client mirror of host -// dsh-scope, keyed by Agent identity); re-exported here so existing -// consumers keep their import site. -export { scopeOf } from '../agents/scope.ts' - -/** - * Workspace display title of a session cwd: the path's last non-empty - * segment (both separators accepted; trailing separators ignored), or '' - * for separator-only paths — callers own their fallback (session id, raw - * cwd, default-directory copy). The repo-wide single basename derivation — - * every surface naming a workspace (picker rows, toggle labels, list titles) - * calls this instead of re-splitting paths. - * @param cwd - workspace directory path. - * @returns basename title, or '' when no non-empty segment exists. - */ -export function workspaceTitleOf(cwd: string): string { - return cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() ?? '' -} - -/** - * Display title projection: durable title, project directory basename, then - * the raw id. - */ -function displayTitleOf(title: string | undefined, cwd: string | undefined, id: SessionId): string { - if (title !== undefined) return title - if (cwd !== undefined && cwd !== '') { - const base = workspaceTitleOf(cwd) - if (base !== '') return base - } - return id -} - -interface ScopeRecord { - fiber: Fiber - ctx: Context - binding: SessionBinding - /** Render-layer standard-props bundle (identity-stable per scope; the renderer's per-info caches key off it). */ - provideInfo: SessionProvideInfo -} - -/** One plugin's per-session standard-props contribution (see {@link SessionsService.provide}). */ -export interface SessionProvideContribution { - /** Bare observable sources, keyed by hook base name ('input' → useInput). */ - hooks?: Record> - /** Stable plain members (action callbacks etc.), spread into standard props verbatim. */ - props?: Record -} - -/** - * Static declaration plus per-session resolver for one standard-kit - * contribution. The declared names let the renderer construct the same hook - * and prop surface while no session is current. - */ -export interface SessionProvideDescriptor { - /** Hook base names (`input` becomes `useInput`). */ - hooks?: readonly string[] - /** Plain standard-prop names. */ - props?: readonly string[] - /** Resolve every declared member for one definite session. */ - resolve(binding: SessionBinding): SessionProvideContribution -} - -/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */ -export class SessionsService { - /** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */ - readonly list: SnapshotStore - /** The object-layer instance cluster and frame dispatch entry. */ - private readonly manager: SessionManager - - /** - * Persisted selection cell (the durable half of `list.current`). Private on - * purpose: reads go through the list snapshot; writes through {@link - * SessionsService.open} / {@link SessionsService.clear}. Projection - * validates it against the live list instead of destructively pruning, so a - * selection survives transient list states (reconnect re-pull) and - * resurfaces when its session returns. - */ - private readonly selection: SnapshotStore<{ sessionId?: SessionId }> - - private readonly scopes = new Map() - /** Registered per-session standard-props providers, in registration order. */ - private readonly providers: SessionProvideDescriptor[] = [] - /** Static no-session projection, rebuilt only when the provider roster changes. */ - private maybeInfo: SessionMaybeProvideInfo - /** - * The staged session id — follows `list.current` exactly, holding its last - * defined value across masked gaps (a transiently absent selection blanks - * `current` without moving the stage, so reconnect re-pulls and removals - * keep the staged scope's frozen view alive until the stage moves on). - */ - private watched: SessionId | undefined - /** Removed-while-staged sessions whose teardown waits for the stage to move away. */ - private readonly deferredRemovals = new Set() - - /** - * @param ctx - client root context (scope fibers mount under it). - * @param api - wire client shared with every Session. - */ - constructor(private readonly rootCtx: Context, api: IApiClient) { - this.selection = createSnapshotStore<{ sessionId?: SessionId }>( - {}, - { persist: { name: 'dsh.sessions.current' } }) - this.manager = new SessionManager(api, this.selection.getSnapshot().sessionId) - this.list = createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'pending', - }) - // The manager owns wire truth; the store is its projection. Manager - // notifications are already microtask-batched. - this.manager.subscribe(() => { this.projectList() }) - // Stage follower: every current write (open() and projection alike) - // re-evaluates staging, so startup restore (persisted selection validated - // by the projection) and reconnect resurfacing open their window with no - // dedicated code path. Safe to run synchronously inside the store notify: - // the follower writes no list state — session.open()'s synchronous prefix - // touches only session-side state and its own microtask-batched notifier. - this.list.subscribe(() => { this.followCurrent() }) - // The runtime's own contribution comes first: useSession rides the same - // provide channel every plugin uses (no renderer special case). - this.providers.push({ - hooks: ['session'], - resolve: binding => ({ hooks: { session: binding.session } }), - }) - this.maybeInfo = this.materializeMaybeProvideInfo() - rootCtx.reflect.provide('sessions', this, undefined) - } - - /** - * Register a per-session standard-props provider: every session-scope slot - * component receives the contributed members as standard props (`hooks` - * sources become `use` selector hooks on the render side; `props` - * spread verbatim). Contributions materialize lazily with the session's - * scope record and die with it. Registration order is resolution order; - * duplicate member names fail loud at materialization. - * @param descriptor - static member roster plus per-session resolver. - * @returns disposer removing the provider (already-materialized bundles keep their members until their scope drops). - */ - provide(descriptor: SessionProvideDescriptor): () => void { - this.providers.push(descriptor) - // Scopes may already exist (boot order: the list lands and resolves - // scopes before later plugins register) — their bundles must include - // every provider by first render, so re-materialize on roster change. - this.rematerializeProvideBundles() - return () => { - const at = this.providers.indexOf(descriptor) - if (at >= 0) this.providers.splice(at, 1) - this.rematerializeProvideBundles() - } - } - - /** Rebuild every live scope's standard-props bundle after a provider roster change. */ - private rematerializeProvideBundles(): void { - this.maybeInfo = this.materializeMaybeProvideInfo() - for (const record of this.scopes.values()) { - record.provideInfo = this.materializeProvideInfo(record.binding) - } - } - - /** Build the static no-session kit and reject duplicate declared names. */ - private materializeMaybeProvideInfo(): SessionMaybeProvideInfo { - const hooks: Record = {} - const props: Record = {} - for (const descriptor of this.providers) { - for (const name of descriptor.hooks ?? []) { - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = undefined - } - for (const name of descriptor.props ?? []) { - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = undefined - } - } - return { sessionId: undefined, hooks, props } - } - - /** Materialize the standard-props bundle for one session (fails loud on duplicate member names). */ - private materializeProvideInfo(binding: SessionBinding): SessionProvideInfo { - const hooks: Record> = {} - const props: Record = {} - for (const descriptor of this.providers) { - const contribution = descriptor.resolve(binding) - const contributedHooks = contribution.hooks ?? {} - const contributedProps = contribution.props ?? {} - for (const name of Object.keys(contributedHooks)) { - if (!(descriptor.hooks ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared hook "${name}"`) - } - } - for (const name of Object.keys(contributedProps)) { - if (!(descriptor.props ?? []).includes(name)) { - throw new Error(`sessions.provide: undeclared prop "${name}"`) - } - } - for (const name of descriptor.hooks ?? []) { - const source = contributedHooks[name] - if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`) - if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`) - hooks[name] = source - } - for (const name of descriptor.props ?? []) { - if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`) - if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`) - props[name] = contributedProps[name] - } - } - return { sessionId: binding.sessionId, hooks, props } - } - - /** - * Select a session as current. Unknown ids fail loud instead of navigating - * nowhere. - * @param id - session id (must exist in the list store). - */ - open(id: SessionId): void { - this.manager.select(id) - } - - /** - * Clear the current selection so the layout shows the no-session empty - * state (new-session affordance and the workspace preselection flow). - * Wipes the persisted selection too — a reload stays on empty until the - * user opens or starts a session. The staged scope keeps its frozen view - * per the masked-gap contract until the next open() moves the stage. - */ - clear(): void { - this.manager.clearSelection() - } - - /** - * Refresh the real Session baseline, reusing an in-flight pull. - * @returns completion of the current or newly started baseline pull. - */ - refresh(): Promise { - return this.manager.refreshList() - } - - /** - * Route a mux stream envelope into the Session object layer. - * @param envelope - validated mux stream envelope. - */ - handleMuxEnvelope(envelope: Parameters[0]): void { - this.manager.handleMuxEnvelope(envelope) - } - - /** - * Route a Host stream envelope into the Session object layer. - * @param envelope - validated Host stream envelope. - */ - handleHostEnvelope(envelope: Parameters[0]): void { - this.manager.handleHostEnvelope(envelope) - } - - /** Rebuild the Session baseline and every opened window after connection. */ - handleConnected(): void { - this.manager.handleConnected() - } - - /** - * Create a session on the host. Resolution guarantee: by the time the - * promise resolves, the created session is in the list store and - * {@link SessionsService.binding} resolves it — callers (New Session - * draft hand-off) may address the scope synchronously, without waiting a - * notifier flush. The synchronous projection below makes this structural - * rather than an accident of microtask ordering. - * @param opts - target workspace or directory and an optional preallocated id. - * @returns the new session id. - * @throws {SessionCreateError} with the requested id. - */ - async create(opts: { workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId } = {}): Promise { - const result = await this.manager.create(opts) - if (!result.ok) throw new SessionCreateError(result.error, opts.sessionId) - this.projectList() - return result.value.sessionId - } - - /** - * Resolve an Agent-scoped context view (use-and-discard). - * @param id - session id (the agent identity — 1:1 same axis). - * @returns scoped ctx, or undefined for a session neither listed nor already scoped. - */ - scope(id: SessionId): Context | undefined { - return this.resolve(id)?.ctx - } - - /** - * Read the Agent scope tag off a context. Service-method seam: fetch - * bundles must reach scope resolution through ctx.sessions — a cross-bundle - * value import of the standalone helper would inline a second module - * instance whose private tag Symbol never matches. - * @param ctx - any client context. - * @returns the session id, or undefined on root contexts. - */ - scopeOf(ctx: Context): SessionId | undefined { - return scopeTagOf(ctx) - } - - /** - * Resolve the business Session behind an Agent-scoped context — the one - * hop every scoped consumer (event listeners, per-session controllers) - * takes from ctx-space into object-space (the client mirror of host - * `agent.session`). Same service-method seam as - * {@link SessionsService.scopeOf}. - * @param ctx - an Agent-scoped context. - * @returns the Session, or undefined when the ctx is untagged or its scope was pruned. - */ - sessionOf(ctx: Context): Session | undefined { - const id = scopeTagOf(ctx) - if (id === undefined) return undefined - return this.scopes.get(id)?.binding.session - } - - /** - * Resolve the stable session binding (scope-addressed assembly feed). Pure - * resolution — no staging, no window side effects. - * @param id - session id. - * @returns binding, or undefined for a session neither listed nor already scoped. - */ - binding(id: SessionId): SessionBinding | undefined { - return this.resolve(id)?.binding - } - - /** - * Resolve the render-layer standard-props bundle (SessionProvider's feed - * through the renderer host; ctx never enters the render layer). Pure - * resolution — render-safe: SessionProvider calls this during render, so no - * staging, no window side effects (StrictMode double-invokes and concurrent - * discarded passes must stay free). - * @param id - session id. - * @returns the provide info, or undefined for a session neither listed nor already scoped. - */ - provideInfo(id: string): SessionProvideInfo | undefined { - return this.resolve(id as SessionId)?.provideInfo - } - - /** - * Resolve the current-session-optional standard kit. Unknown or absent ids - * return the static no-session projection rather than removing hook props. - * @param id - current session id, when selected. - * @returns a definite or no-session provide bundle. - */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { - return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo - } - - /** - * Move the stage to the list's current session: sweep teardowns deferred - * behind the previous occupant and pull the new occupant's history window. - * Staging IS the open signal — the window opens ⟺ the session is on stage - * — and open() is idempotent (an in-flight or completed open no-ops; a - * failed one retries the next time current is touched). - */ - private followCurrent(): void { - const snapshot = this.list.getSnapshot() - const current = snapshot.current - // A masked gap (current blanked while the selection's session is - // transiently absent) holds the stage: tearing down on the gap would - // destroy exactly the frozen scope the mask exists to preserve. - if (current === undefined || snapshot.byId[current] === undefined || current === this.watched) return - this.watched = current - this.sweepDeferred() - const record = this.resolve(current) - /* v8 ignore next 3 -- defensive: current is always a listed id (open() - * validates and the projection masks absent selections), so resolve - * cannot miss; kept so a future current writer cannot crash the notify. */ - if (record !== undefined) { - void record.binding.session.open() - } - } - - /** - * Breadcrumb feed: walk parentId links inside the list store. - * @param id - session id. - * @returns summaries from root ancestor to the session itself (empty when unknown; a broken link stops the walk). - */ - ancestry(id: SessionId): SessionSummary[] { - const { byId } = this.list.getSnapshot() - const chain: SessionSummary[] = [] - let cursor: SessionId | undefined = id - while (cursor !== undefined) { - const summary: SessionSummary | undefined = byId[cursor] - if (summary === undefined || chain.includes(summary)) break - chain.unshift(summary) - cursor = summary.parentId - } - return chain - } - - /** - * Lazily mint the scope + binding for an eligible session. Eligibility and - * prune share one predicate (decision 12): listed on the host — a scope is - * born when its session enters the client's view (list mirror row from the - * baseline pull, a create() echo, or the session-added frame) and dies with - * the prune when the row leaves. - */ - private resolve(id: SessionId): ScopeRecord | undefined { - const existing = this.scopes.get(id) - if (existing !== undefined) return existing - if (!this.eligible(id)) return undefined - const { fiber, ctx } = createScope(this.rootCtx, id) - const session = this.manager.get(id) - // The Session owns its scoped dispatch point (host Agent.loopCtx mirror); - // mint and bind are one step so a live scope record implies a bound actx. - session.bindScope(ctx) - const binding: SessionBinding = { sessionId: id, session, ctx } - const record: ScopeRecord = { - fiber, - ctx, - binding, - // Sources are bare observables; React binds selector hooks at its own seam. - provideInfo: this.materializeProvideInfo(binding), - } - this.scopes.set(id, record) - return record - } - - /** The one aliveness predicate shared by scope mint and prune: host-listed. */ - private eligible(id: SessionId): boolean { - return this.list.getSnapshot().byId[id] !== undefined - } - - /** Project the manager's list snapshot into the store (title derivation is display-only). */ - private projectList(): void { - const { items, current, phase } = this.manager.getListSnapshot() - const ids: SessionId[] = [] - const byId: Record = {} - for (const entry of items) { - ids.push(entry.sessionId) - byId[entry.sessionId] = { - id: entry.sessionId, - displayTitle: displayTitleOf(entry.title, entry.cwd, entry.sessionId), - running: entry.running, - blank: entry.blank, - updatedAt: entry.updatedAt, - ...(entry.title !== undefined ? { title: entry.title } : {}), - ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), - ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), - } - } - const persisted = this.selection.getSnapshot().sessionId - // No current (cleared, or masked gap) wipes the persisted cell — a reload - // stays on empty; the in-memory selection still resurfaces a masked id. - if (current === undefined) { - if (persisted !== undefined) this.selection.set({}) - } else if (byId[current] !== undefined && persisted !== current) { - this.selection.set({ sessionId: current }) - } - this.list.set({ ids, byId, current, phase }) - this.pruneScopes(byId) - } - - /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */ - private pruneScopes(byId: Record): void { - void byId - for (const [id, record] of this.scopes) { - if (this.eligible(id)) continue - if (id === this.watched) { - this.deferredRemovals.add(id) - continue - } - this.scopes.delete(id) - this.deferredRemovals.delete(id) - this.dropScope(id, record) - } - } - - /** - * One teardown for the whole per-session axis (decision 12): the scope - * fiber (cascading every actx-registered effect: input shell, slash - * controller, popup, plugin stores, listeners), the session-keyed slot - * stores, and the Session instance itself — the host session log is the - * durable truth, a reopen lazily rebuilds and backfills via open(). - */ - private dropScope(id: SessionId, record: ScopeRecord): void { - void record.fiber.dispose() - // Release the Session's dispatch point with the scope it belongs to (a - // surviving instance — the live Intent — rebinds when resolve re-mints). - record.binding.session.unbindScope() - // Optional lookup: slots and sessions are sibling services with no - // declared dependency; a slots-less boot (object-layer tests) skips. - this.rootCtx.get('slots')?.pruneStoreScope(id) - this.manager.drop(id) - } - - /** Run deferred teardowns whose session is no longer staged (called when the stage moves). */ - private sweepDeferred(): void { - for (const id of [...this.deferredRemovals]) { - /* v8 ignore next -- defensive: only the staged id ever defers, and every - * stage move sweeps first, so the set cannot contain the id the stage just - * moved to; kept as a guard against future extra sweep call sites. */ - if (id === this.watched) continue - // Eligible again? (A re-added id cancels the deferred teardown.) - if (this.eligible(id)) { - this.deferredRemovals.delete(id) - continue - } - const record = this.scopes.get(id) - this.deferredRemovals.delete(id) - /* v8 ignore next -- defensive: prune deletes a scope and its deferral - * together, so a deferred id always still owns its record; kept so a - * future teardown path cannot double-dispose. */ - if (record !== undefined) { - this.scopes.delete(id) - this.dropScope(id, record) - } - } - } -} diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 8929ac1ad8..55dd148c50 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -3,7 +3,7 @@ import type { Context } from 'cordis' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, PromptContentPart, RpcError, RpcId, RpcResult, SessionId, ToolEventView, @@ -100,6 +100,9 @@ export class Session implements ObservableSnapshot { private queueCache: { rev: number; value: QueuedMessage[] } | null = null private frozenRev = 0 private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null + /** Current whole-list todo/write projection: each tail history response replaces it (an omitted + * field is the authoritative empty list) and every live write overwrites it. */ + private todos: readonly TodoItem[] = [] /** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends * copy-on-write the per-parent array so published snapshot references never mutate. */ private codeDispatches = new Map() @@ -502,13 +505,13 @@ export class Session implements ObservableSnapshot { this.openError = result.error return } - this.installWindow(result.value.events, result.value.hasMore) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos) // Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more. const tailSeq = this.windowTailSeq() if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) { result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result if (generation !== this.openGeneration) return - if (result.ok) this.installWindow(result.value.events, result.value.hasMore) + if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos) } this.openState = 'open' } catch (error) { @@ -526,11 +529,19 @@ export class Session implements ObservableSnapshot { * Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here * (doOpen flips it after install), so recursing would push every buffered event straight * back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */ - private installWindow(entries: HistoryEntry[], hasMore: boolean): void { + private installWindow(entries: HistoryEntry[], hasMore: boolean, todos: readonly TodoItem[] | undefined): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore + // Session-level projection from the tail page (full-log latest todo/write, + // independent of the window); an in-window write below re-derives the same + // value, and later live events keep overwriting it. Every caller here is a + // tail request (no beforeSeq), which the host answers with the projection + // or omits it only when the full log holds no todo/write — so an absent + // field is the authoritative empty list, not a missing carrier. Assigning + // it clears a plan the log never kept (a write lost to a host crash). + this.todos = todos ?? [] this.foldAdapter.reset(this.events, this.baseSeq, this.views) this.rebuildDerivedFromWindow() const buffered = this.liveBuffer @@ -581,7 +592,7 @@ export class Session implements ObservableSnapshot { const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES }) // Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself. if (result.ok && generation === this.openGeneration && this.openState === 'open') { - this.installWindow(result.value.events, result.value.hasMore) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos) } } catch (error) { console.error('[web-runtime] gap repair failed:', error) @@ -701,6 +712,10 @@ export class Session implements ObservableSnapshot { if (this.openCalls.delete(String(event.data.callId))) this.callsRev++ return } + case 'todo/write': { + this.todos = event.data.todos + return + } case 'turn/end': { // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. @@ -745,7 +760,10 @@ export class Session implements ObservableSnapshot { /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes the live freeze and the history replay converge on the - * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ + * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). + * todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log + * projection, not derivable from an arbitrary window). The window always extends to the log + * tail, so an in-window todo/write can only overwrite it with the same latest value. */ private rebuildDerivedFromWindow(): void { this.partial = null this.openCalls.clear() @@ -815,6 +833,7 @@ export class Session implements ObservableSnapshot { promptError: this.promptError, blank: this.blankBit, lastAgentError: this.lastAgentError, + todos: this.todos, } } } diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index e7caecfe82..ce4198cd01 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -19,6 +19,10 @@ export interface WorkspaceListSnapshot { error: RpcError | null } +type WorkspaceDelta = + | { type: 'upsert'; workspace: WorkspaceView } + | { type: 'remove'; workspaceId: WorkspaceId } + /** Workspace object cluster driven by one list baseline and changed-frame upserts. */ export class WorkspaceManager { private items: Workspace[] = [] @@ -28,7 +32,16 @@ export class WorkspaceManager { private phase: WorkspaceListPhase = 'pending' private error: RpcError | null = null private inflight: Promise | null = null - private refreshFrames: WorkspaceView[] | null = null + private refreshFrames: WorkspaceDelta[] | null = null + /** + * Ids this process has seen removed, kept for the connection's lifetime so + * a late changed frame or a stale baseline row cannot resurrect a deleted + * row. Correctness rests on Host ids never being reused (the registry mints + * a fresh `randomUUID` per record, including when the same directory is + * registered again) — a path-derived id scheme would turn these entries + * into permanent blindfolds and must clear them instead. + */ + private readonly removedIds = new Set() private snapshotCache: WorkspaceListSnapshot private readonly notifier = new Notifier(() => { this.snapshotCache = this.buildSnapshot() @@ -51,7 +64,7 @@ export class WorkspaceManager { this.state = 'loading' this.error = null const established = this.itemViews() - const frames: WorkspaceView[] = [] + const frames: WorkspaceDelta[] = [] this.refreshFrames = frames this.notifier.markDirty() this.inflight = (async () => { @@ -61,7 +74,8 @@ export class WorkspaceManager { let items = this.phase === 'pending' ? result.value.items : mergeOrderedBaseline(established, result.value.items, workspace => workspace.workspaceId) - for (const workspace of frames) items = upsertWorkspace(items, workspace) + items = items.filter(workspace => !this.removedIds.has(workspace.workspaceId)) + for (const delta of frames) items = applyWorkspaceDelta(items, delta) this.installViews(items) this.state = 'idle' this.phase = 'ready' @@ -111,6 +125,18 @@ export class WorkspaceManager { return result } + /** + * Delete a Workspace registration and remove its local projection from the + * unary response without waiting for the Host frame. + * @param workspaceId - target workspace. + * @returns the wire result. + */ + async delete(workspaceId: WorkspaceId): Promise> { + const { result } = await this.api.workspace.delete({ workspaceId }) + if (result.ok) this.remove(workspaceId, true) + return result + } + /** * Move a session within its Workspace's manual order, then publish the * returned snapshot without waiting for the changed frame. @@ -139,6 +165,7 @@ export class WorkspaceManager { */ handleHostEnvelope(envelope: RpcRequest): void { if (envelope.payload.type === 'host/workspace-changed') this.upsert(envelope.payload.workspace) + else if (envelope.payload.type === 'host/workspace-removed') this.remove(envelope.payload.workspaceId) } /** Re-pull the baseline after each connection generation. */ @@ -175,7 +202,8 @@ export class WorkspaceManager { /** Upsert one Host view, optionally retaining the local object that materialized it. */ private upsert(view: WorkspaceView, identity?: Workspace): void { - this.refreshFrames?.push(view) + if (this.removedIds.has(view.workspaceId)) return + this.refreshFrames?.push({ type: 'upsert', workspace: view }) const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) // Mutation responses and changed frames race (two carriers, no ordering): // reject a snapshot strictly older than the installed projection so a @@ -195,6 +223,24 @@ export class WorkspaceManager { this.notifier.markDirty() } + /** Remove one id idempotently and retain a tombstone against late echoes. */ + private remove(workspaceId: WorkspaceId, direct = false): void { + this.refreshFrames?.push({ type: 'remove', workspaceId }) + this.removedIds.add(workspaceId) + const items = this.items.filter(item => + item.getSnapshot().view?.workspaceId !== workspaceId) + if (items.length === this.items.length) { + // The Host frame may have removed the row first but left its batched + // notification pending. A successful unary echo still flushes that + // committed state before the user action resolves. + if (direct) this.notifier.notifyNow() + return + } + this.items = items + if (direct) this.notifier.notifyNow() + else this.notifier.markDirty() + } + private installViews(views: readonly WorkspaceView[]): void { const existing = new Map( this.items.flatMap((workspace) => { @@ -234,3 +280,10 @@ function upsertWorkspace(items: readonly WorkspaceView[], workspace: WorkspaceVi ? [workspace, ...items] : items.map((item, position) => position === index ? workspace : item) } + +/** Replay one ordered delta over a baseline: upsert in place, or drop the removed id. */ +function applyWorkspaceDelta(items: readonly WorkspaceView[], delta: WorkspaceDelta): WorkspaceView[] { + return delta.type === 'upsert' + ? upsertWorkspace(items, delta.workspace) + : items.filter(workspace => workspace.workspaceId !== delta.workspaceId) +} diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 1e281ca792..fe345801c6 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -21,6 +21,14 @@ export interface WorkspaceListState { recentWorkspaceId: WorkspaceId | undefined } +/** Structured create failure for UI flows that distinguish Host business errors. */ +export class WorkspaceCreateError extends Error { + constructor(readonly rpcError: RpcError) { + super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`) + this.name = 'WorkspaceCreateError' + } +} + /** Real Workspace object layer and Host actions. */ export class WorkspacesService { /** UI-facing immutable projection; the manager remains wire truth. */ @@ -37,7 +45,7 @@ export class WorkspacesService { * @param api - shared wire client. * @param sessions - lower-level Session service used for recency and blank-session reuse. */ - constructor(ctx: Context, api: IApiClient, private readonly sessions: SessionsService) { + constructor(ctx: Context, private readonly api: IApiClient, private readonly sessions: SessionsService) { this.manager = new WorkspaceManager(api) this.list = createSnapshotStore({ items: [], state: 'idle', phase: 'pending', error: null, @@ -158,10 +166,22 @@ export class WorkspacesService { */ async create(input: { name: string } | { path: string }): Promise { const result = await this.manager.create(input) - if (!result.ok) throw new Error(`workspace create failed: ${result.error.code}: ${result.error.message}`) + if (!result.ok) throw new WorkspaceCreateError(result.error) return result.value.workspace } + /** + * Open the Host's native directory picker. + * @returns the selected path, or null when the user cancelled. + */ + async pickDirectory(): Promise { + const response = await this.api.host.pickDirectory({}) + if (!response.result.ok) { + throw new Error(`directory picker failed: ${response.result.error.message}`) + } + return response.result.value.path + } + /** * Rename a Workspace. * @param workspaceId - target workspace. @@ -174,6 +194,16 @@ export class WorkspacesService { return result.value.workspace } + /** + * Delete one Workspace registration. Sessions, session logs, and the + * directory remain Host-owned outside this operation. + * @param workspaceId - target workspace. + */ + async delete(workspaceId: WorkspaceId): Promise { + const result = await this.manager.delete(workspaceId) + if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`) + } + /** * Move a session within its Workspace's manual order (DOM-insertBefore-like). * @param workspaceId - owning workspace. diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index 7fb150bb20..ada8550136 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -40,6 +40,8 @@ export const ev = { at(seq, { type: 'step/end', data: { turn, step } }), turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), + todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent => + at(seq, { type: 'todo/write', data: { todos } }), } /** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */ diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 8db23629f5..8098ee8714 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -2,8 +2,8 @@ // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. import type { - ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, MuxFrame, - RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SkillEntry, + ClientResponse, CommandDescriptor, CommandExecuteResult, HostFrame, IApiClient, ModelTarget, MuxFrame, + RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' @@ -61,16 +61,31 @@ export class FakeApiClient implements IApiClient { // Programmable slots (defaults answer OK-empty); reassign per case. onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) + readonly defaultModel: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' } onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) + onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ + current: this.defaultModel, + groups: [{ + id: 'deepseek', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek V4 Flash' }], + }], + failures: [], + })) + onSelectModel: (payload: { provider: string; model: string }) => + Promise> = + payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onAttachment: (payload: unknown) => Promise> = () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) private readonly muxConns: StreamConn[] = [] private readonly hostConns: StreamConn[] = [] @@ -83,6 +98,9 @@ export class FakeApiClient implements IApiClient { create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)), history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) => this.record('session.history', payload, this.onHistory(payload)), + models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)), + selectModel: (payload: { provider: string; model: string }) => + this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), @@ -90,6 +108,7 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), + pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)), } onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) @@ -99,6 +118,9 @@ export class FakeApiClient implements IApiClient { onWorkspaceRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + onWorkspaceDelete: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ deleted: true })) + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) @@ -106,6 +128,7 @@ export class FakeApiClient implements IApiClient { list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + delete: (payload: unknown) => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), } @@ -113,12 +136,10 @@ export class FakeApiClient implements IApiClient { // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program requires-bearing catalogs and dual-address // skill lists without casts. - onCommandList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ commands: [] })) - onCommandExecute: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ matched: false })) - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) + onCommandList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ commands: [] })) + onCommandExecute: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ matched: false })) + onSkillList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ skills: [] })) readonly commands: IApiClient['commands'] = { list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)), diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index ee76d885ab..039a48894c 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -346,7 +346,11 @@ describe('remaining branches', () => { describe('connected generation', () => { it('refreshes the list and resyncs only opened instances', async () => { const api = new FakeApiClient() - api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) + api.onHistory = () => Promise.resolve(ok({ + events: entries(plainTurn(0, 0, 'a', 'b')) as never[], + hasMore: false, + modelTarget: { provider: 'deepseek', model: 'deepseek-chat' }, + })) const manager = new SessionManager(api) const openedSession = manager.get(S1) await openedSession.open() diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 360f7c1a9d..6fe9f33c80 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -20,7 +20,8 @@ const rid = (id: string): RpcId => id as RpcId function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame { return { type: 'session/queued', sessionId: SID, content: text(body), - source: { kind: 'user', rpcId: rid(rpcId) } as never, steering, + source: { kind: 'user', rpcId: rid(rpcId) } as never, + steering, } } @@ -41,7 +42,8 @@ describe('queue intake', () => { session.handleMuxEnvelope(rid('env-2'), { type: 'session/queued', sessionId: SID, content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], - source: { kind: 'plugin', plugin: 'loop' }, steering: false, + source: { kind: 'plugin', plugin: 'loop' }, + steering: false, }) expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }]) }) @@ -85,22 +87,22 @@ describe('queue retirement (host queuedMirror rules)', () => { it('steering/message drains the source-matched steering row only', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) - session.handleMuxEnvelope(rid('e2'), queuedFrame('插话', 'p-2', true)) + session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering + session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2', true)) // Loop-authored steering (different source) must not consume the user entry. const foreignSteering = { seq: 0, time: 1, type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: text('loop'), source: { kind: 'plugin', plugin: 'loop' } }, } as never - session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: foreignSteering }) + session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering }) expect(session.getSnapshot().queue).toHaveLength(2) const matchedSteering = { seq: 1, time: 2, type: 'steering/message', surfaceOp: 'append', data: { turn: 0, content: text('插话'), source: { kind: 'user', rpcId: rid('p-2') } }, } as never - session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: matchedSteering }) + session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering }) expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1']) }) @@ -108,7 +110,7 @@ describe('queue retirement (host queuedMirror rules)', () => { const session = makeSession() session.handleRunning(true) session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1')) - session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2', true)) + session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2')) session.handleRunning(false) expect(session.getSnapshot().queue).toEqual([]) }) @@ -144,6 +146,19 @@ describe('queue reconnect semantics', () => { await session.resync() expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh']) }) + + it('replayed steering retires without a replayed turn/start', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 }) + session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true)) + const committed = { + seq: 6, time: 2, + type: 'steering/message', surfaceOp: 'append', + data: { turn: 1, content: text('重连插话'), source: { kind: 'user', rpcId: rid('p-steer') } }, + } as never + session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed }) + expect(session.getSnapshot().queue).toEqual([]) + }) }) describe('manager buffering of queued frames', () => { diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index ffb2e84f9e..89894e2504 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: return { api, session: new Session(SID, api) } } -function histResponse(events: SessionEvent[], hasMore = false) { +function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) { // history now returns HistoryEntry[] ({event, view?}); these tests are view-less. - return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) + return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } })) } describe('open', () => { @@ -75,7 +75,11 @@ describe('open', () => { const page = plainTurn(10, 0, '早', '安') session.handleMuxEnvelope('r1' as never, { type: 'session/event', sessionId: SID, event: ev.turnStart(15, 1) }) session.handleMuxEnvelope('r2' as never, { type: 'session/event', sessionId: SID, event: ev.user(16, '插进来的') }) - gate.resolve(ok({ events: entries(page) as never[], hasMore: false })) + gate.resolve(ok({ + events: entries(page) as never[], + hasMore: false, + modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + })) await opening const seqs = session.getSnapshot().nodes.map(n => n.seq) // Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once. @@ -83,6 +87,7 @@ describe('open', () => { }) }) + describe('live event path', () => { async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) { const { api, session } = makeSession() @@ -153,6 +158,42 @@ describe('live event path', () => { }) }) + it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => { + const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }] + const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }] + const { session } = await opened() + expect(session.getSnapshot().todos).toEqual([]) + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.todoWrite(6, listA)) + expect(session.getSnapshot().todos).toEqual(listA) + feed(ev.todoWrite(7, listB)) + expect(session.getSnapshot().todos).toEqual(listB) + // Window replay converges on the same last snapshot (history contains both writes). + const replayed = makeSession() + replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)]) + await replayed.session.open() + expect(replayed.session.getSnapshot().todos).toEqual(listB) + }) + + it('seeds todos from the tail page projection when the last write precedes the window', async () => { + const list = [{ content: '窗口外的计划', status: 'in_progress' as const }] + // Cold open: the page window carries NO todo/write; the projection rides the response. + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list) + await session.open() + expect(session.getSnapshot().todos).toEqual(list) + // Paging an older window in must not clear the session-level projection. + api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false) + await session.loadOlder() + expect(session.getSnapshot().todos).toEqual(list) + // A later live write still overrides the seeded projection. + session.handleMuxEnvelope('r' as never, { + type: 'session/event', sessionId: SID, + event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]), + }) + expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }]) + }) + it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] @@ -166,6 +207,37 @@ describe('live event path', () => { const seqs = session.getSnapshot().nodes.map(n => n.seq) expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9 }) + + it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => { + const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 + expect(session.getSnapshot().todos).toEqual([]) + // The missed range contained a todo/write that the repulled page no longer + // covers; the response's session-level projection is the only carrier. + const current = [{ content: '断线期间写的', status: 'in_progress' as const }] + api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current) + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') }) + await vi.waitFor(() => { + expect(api.callsOf('session.history').length).toBe(2) + }) + await Promise.resolve() + expect(session.getSnapshot().todos).toEqual(current) + }) + + it('clears the plan when a tail response omits the projection (a write the log never kept)', async () => { + // Live write lands, then the host crashes before persisting it: the + // authoritative log holds no todo/write, so the resync tail response + // carries no projection — an omitted field on a tail request is the empty + // list, not a missing carrier, and the rolled-back plan must disappear. + const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) + session.handleMuxEnvelope('r' as never, { + type: 'session/event', sessionId: SID, + event: ev.todoWrite(6, [{ content: '丢失的计划', status: 'in_progress' as const }]), + }) + expect(session.getSnapshot().todos).toEqual([{ content: '丢失的计划', status: 'in_progress' }]) + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.resync() + expect(session.getSnapshot().todos).toEqual([]) + }) }) describe('paging', () => { @@ -210,7 +282,11 @@ describe('paging', () => { api.onHistory = () => gate.promise const first = session.loadOlder() const second = session.loadOlder() - gate.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) + gate.resolve(ok({ + events: entries(plainTurn(0, 0, 'a', 'b')) as never[], + hasMore: false, + modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + })) await Promise.all([first, second]) expect(api.callsOf('session.history')).toHaveLength(2) // open + one page, not two }) @@ -511,7 +587,11 @@ describe('remaining branches', () => { const opening = session.open() api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代')) const resynced = session.resync() - stale.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '代')) as never[], hasMore: false })) // success, but its generation is gone + stale.resolve(ok({ + events: entries(plainTurn(0, 0, '旧', '代')) as never[], + hasMore: false, + modelTarget: { provider: 'deepseek', model: 'stale' }, + })) // success, but its generation is gone await Promise.all([opening, resynced]) expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) // only the fresh generation's window }) @@ -530,7 +610,11 @@ describe('remaining branches', () => { const opening = session.open() // triggers the second pull, which parks await vi.waitFor(() => { expect(call).toBe(2) }) const resynced = session.resync() - secondPull.resolve(ok({ events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], hasMore: false })) + secondPull.resolve(ok({ + events: entries([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) as never[], + hasMore: false, + modelTarget: { provider: 'deepseek', model: 'stale' }, + })) await Promise.all([opening, resynced]) expect(session.getSnapshot().openState).toBe('open') }) @@ -544,7 +628,11 @@ describe('remaining branches', () => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.user(9, '洞') }) // starts repairGap api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd')) const resynced = session.resync() // bumps the generation - repairPull.resolve(ok({ events: entries(plainTurn(0, 0, '旧', '页')) as never[], hasMore: false })) // repair result: stale, dropped + repairPull.resolve(ok({ + events: entries(plainTurn(0, 0, '旧', '页')) as never[], + hasMore: false, + modelTarget: { provider: 'deepseek', model: 'stale' }, + })) // repair result: stale, dropped await resynced expect(session.getSnapshot().nodes.map(n => n.seq)).toEqual([7, 9]) }) @@ -588,6 +676,7 @@ describe('remaining branches', () => { { event: ev.toolResult(7, 1, 'h1', 'done'), view: { for: 'result', view: { card: 'generic', title: '历史果' } } }, ] as never[], hasMore: false, + modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' }, })) await session.open() expect(session.getSnapshot().nodes.at(-1)).toMatchObject({ diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index d020b74fec..5327066651 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import { SessionsService } from '../src/client/sessions/service.ts' import { WorkspaceManager } from '../src/client/workspaces/manager.ts' -import { WorkspacesService } from '../src/client/workspaces/service.ts' +import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts' import { FakeApiClient, deferred, err, ok } from './fake-api.ts' const sid = (id: string): SessionId => id as SessionId @@ -76,6 +76,48 @@ describe('WorkspaceManager', () => { ok: false, error: { code: 'internal', message: 'create transport' }, }) }) + + it('replays removal over an in-flight baseline and ignores duplicate or late updates', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const manager = new WorkspaceManager(api) + const hydration = manager.refresh() + manager.handleHostEnvelope({ + rpcId: 'removed' as never, + payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, + }) + gate.resolve(ok({ items: [workspace('gone'), workspace('kept')] as never[] })) + await hydration + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + + manager.handleHostEnvelope({ + rpcId: 'late-change' as never, + payload: { type: 'host/workspace-changed', workspace: workspace('gone') }, + }) + manager.handleHostEnvelope({ + rpcId: 'duplicate-remove' as never, + payload: { type: 'host/workspace-removed', workspaceId: wid('gone') }, + }) + expect(manager.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + }) + + it('removes from the unary delete echo while a refresh is in flight', async () => { + const api = new FakeApiClient() + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('gone')] as never[] })) + const manager = new WorkspaceManager(api) + await manager.refresh() + const gate = deferred>>() + api.onWorkspaceList = () => gate.promise + const refresh = manager.refresh() + + await expect(manager.delete(wid('gone'))).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.delete')).toEqual([{ workspaceId: 'gone' }]) + expect(manager.getSnapshot().items).toEqual([]) + gate.resolve(ok({ items: [workspace('gone')] as never[] })) + await refresh + expect(manager.getSnapshot().items).toEqual([]) + }) }) describe('WorkspacesService', () => { @@ -168,11 +210,45 @@ describe('WorkspacesService', () => { const api = new FakeApiClient() const sessions = new SessionsService(ctx, api) const workspaces = new WorkspacesService(ctx, api, sessions) - await expect(workspaces.create({ path: '/w/existing' })).resolves.toMatchObject({ workspaceId: 'fk-ws' }) - expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/existing' }]) + api.onWorkspaceCreate = () => Promise.resolve(ok({ + workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true, + })) + await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' }) + expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }]) api.onWorkspaceCreate = () => Promise.resolve(err({ code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' }, })) - await expect(workspaces.create({ path: '/missing' })).rejects.toThrow(/workspace-invalid-path: missing/) + const rejected = workspaces.create({ path: '/missing' }) + await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/) + await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError) + }) + + it('passes native directory selection and cancellation through without local state', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' })) + await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha') + api.onPickDirectory = () => Promise.resolve(ok({ path: null })) + await expect(workspaces.pickDirectory()).resolves.toBeNull() + expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}]) + }) + + it('deletes a Workspace or preserves it when the Host rejects deletion', async () => { + const ctx = new Context() + const api = new FakeApiClient() + const sessions = new SessionsService(ctx, api) + const workspaces = new WorkspacesService(ctx, api, sessions) + api.onWorkspaceList = () => Promise.resolve(ok({ items: [workspace('alpha')] as never[] })) + await workspaces.refresh() + await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined() + expect(workspaces.list.getSnapshot().items).toEqual([]) + + api.onWorkspaceDelete = () => Promise.resolve(err({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: 'ghost' }, + })) + await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/) }) }) diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx index 9afd432d37..bda650e190 100644 --- a/packages/client/ui-command/tests/popup-view.spec.tsx +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -45,7 +45,7 @@ async function mountOpen(overrides: Partial> = {}, consumeResu } function rowLabels(): string[] { - return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent!) + return screen.getAllByRole('option').map(o => o.querySelector('span')!.textContent) } describe('PopupSelectView', () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b0b9dc607f..db40c98d21 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: a5fbfb4ce85b2802522bd2d99e9e79389c0faa97 -README.zh.md: 9e090ed7e528c473519ab02f1ac2df52222cd123 +README.md: 6e90e79465e12b32c404cb90b5ed833a4dffe1b3 +README.zh.md: f8b64878707d8434c2b33b607cc05f4734a825d6 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index a5fbfb4ce8..6e90e79465 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,17 +2,21 @@ English | [中文](README.zh.md) -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant and the todo row), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). -The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService. +The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · ` or `Edit · ` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -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. +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. + +Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. + +The composer bar declares session-scoped single seats for `'conversation.input.plan'` 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. Image drafts keep only ordered runtime ids in that store. `ConversationService` owns the corresponding browser `File` and object URLs, applies the latest host capability and upload-limit snapshot before allocation, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. Paste and drop share the same validation path; mixed clipboard text remains native textarea input. @@ -33,3 +37,4 @@ None; this package neither assembles nor sends a provider request. - **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project. +- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 9e090ed7e5..f8b6487870 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,17 +2,21 @@ [English](README.md) | 中文 -会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 +会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 -无会话主视觉区会渲染来自 Session 列表投影的前端 Session Intent;没有真实 Workspace 时,还会包含其前端 Workspace Intent。它声明 `conversation.empty.workspace`,ui-workspace 会在此注册侧边栏所用的同一选择器。WorkspacesService 启动跨对象流程;每个 Workspace 或 Session 对象拥有自身的物化。Session 在发布期间保持身份,并保留任何仍需连接或交付的提示词;ConversationRoot 读取该 `pendingPrompt`,其来源是 `useSession`,再通过 scope 内的 ConversationService 编辑或重试。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。 视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: `);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · ` 或 `Edit · ` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -逐 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 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 + +逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 + +输入栏为 `'conversation.input.plan'` 和 `'conversation.input.model'` 声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送/停止按钮之前。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。 图片草稿在该 store 中只保留有序的运行时 id。`ConversationService` 持有对应的浏览器 `File` 和对象 URL,在分配前应用最新的宿主能力与上传限制快照,并在图片移除或发送时释放草稿 URL,在所渲染的会话卸载时释放历史 URL。粘贴与拖放共用同一校验路径;混合剪贴板文本仍由 textarea 原生输入。 @@ -33,3 +37,4 @@ - **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。 +- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 4620019e92..91d66f99fe 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -13,6 +13,8 @@ import { InputHub } from './input/hub.ts' import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { todoToolview } from './toolviews/todo-row.tsx' +import { todoDockEntry } from './skeleton/TodoPanel.tsx' import { queueDockEntry } from './queue/QueueDock.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { ConversationSession } from './skeleton/ConversationSession.tsx' @@ -88,26 +90,23 @@ export function apply(ctx: Context): void { 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ - selectWorkspace: (workspaceId) => { - void workspaces.connectWorkspace(workspaceId).then((nextId) => { - if (sessionId !== undefined && nextId !== sessionId) { - const from = inputHub.shell(sessionId) - const draft = from.snapshot.draft - const imageIds = from.snapshot.imageIds - const next = inputHub.shell(nextId) - if (draft !== '') { - next.setDraft(draft) - from.setDraft('') - } - if (imageIds.length > 0) { - next.addImages(imageIds) - for (const id of imageIds) from.removeImage(id) - } + selectWorkspace: async (workspaceId) => { + const nextId = await workspaces.connectWorkspace(workspaceId) + if (sessionId !== undefined && nextId !== sessionId) { + const from = inputHub.shell(sessionId) + const draft = from.snapshot.draft + const imageIds = from.snapshot.imageIds + const next = inputHub.shell(nextId) + if (draft !== '') { + next.setDraft(draft) + from.setDraft('') } - sessions.open(nextId) - }).catch(() => { - // Failure leaves the current Hero state available to retry. - }) + if (imageIds.length > 0) { + next.addImages(imageIds) + for (const id of imageIds) from.removeImage(id) + } + } + sessions.open(nextId) }, }), }, ConversationRoot) @@ -210,9 +209,16 @@ export function apply(ctx: Context): void { // 'conversation.chat.toolview' declaration) is on the ledger. ctx.plugin(ConversationService, { input: inputHub }) - // The bash sample rides that exact seam, in third-party posture. + // The bash sample rides that exact seam, in third-party posture + // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions). ctx.plugin(bashToolviewSample) + // The todo_write row rides the same seam (a product registration, not a sample). + ctx.plugin(todoToolview) + + // The plan strip rides the input dock above the queue rows (same posture). + ctx.plugin(todoDockEntry) + // The read-only queue dock entry (T9 file territory) rides the same // registration seam into the input dock declared above. ctx.plugin(queueDockEntry) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index d548f2d7be..6d75f9a519 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -37,17 +37,11 @@ border-radius: 6px; } -/* Selection linkage: the selected call row wears the blue outline. - button-info-fill flips 500→400 with the theme, hitting the darker-blue - dark-mode spec exactly (business-primary stays 500 on both). */ -.callRow[data-selected] { - outline: 1.5px solid var(--dsw-alias-button-info-fill); - outline-offset: 1px; -} +/* Selection still sets data-selected for details linkage; no outline — + tool rows match Think chrome (no selected ring). */ /* run_code sub-dispatch rows: indented under the parent row, left-edged so - the code turn reads as one unit; each nested row is itself a .callRow - (same components, same selection outline as top-level rows). */ + the code turn reads as one unit; each nested row is itself a .callRow. */ .subCalls { display: flex; flex-direction: column; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 2154f1fa94..e614904720 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -87,7 +87,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq seq: number onOpenDetails: OpenDetails selected: boolean - /** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */ + /** `run_code` sub-dispatches in dispatch order (reference-stable per + * parent; running entries settle in place); undefined for ordinary calls. */ subCalls?: readonly CodeSubCall[] | undefined /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ selectedCallId?: string | undefined @@ -104,7 +105,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq })} {subCalls !== undefined && subCalls.length > 0 && (
- {subCalls.map((node) => ( + {subCalls.map(node => ( - {results.map((node) => ( + {results.map(node => ( void loadImage: ImageLoader }) { - const partial = useSession((s) => s.partial) + const partial = useSession(s => s.partial) useLayoutEffect(() => { onGrow() }) @@ -164,17 +165,20 @@ function StreamingTail({ useSession, onGrow, loadImage }: { return } -/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */ +/** + * The chat view slot entry: pure component over the composed props (tool rows + * render through the declared keyed hole's renderSlot share). + */ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder, loadImage }: ChatViewSlotProps) { - const nodes = useSession((s) => s.nodes) - const runningCalls = useSession((s) => s.runningCalls) - const codeDispatches = useSession((s) => s.codeDispatches) - const pending = useSession((s) => s.pending) - const openState = useSession((s) => s.openState) - const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) - const hasMore = useSession((s) => s.hasMore) - const loadingOlder = useSession((s) => s.loadingOlder) - const selectedCallId = useStore((s) => s.selection?.callId) + const nodes = useSession(s => s.nodes) + const runningCalls = useSession(s => s.runningCalls) + const codeDispatches = useSession(s => s.codeDispatches) + const pending = useSession(s => s.pending) + const openState = useSession(s => s.openState) + const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`) + const hasMore = useSession(s => s.hasMore) + const loadingOlder = useSession(s => s.loadingOlder) + const selectedCallId = useStore(s => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) @@ -256,8 +260,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl const renderItem = (item: ChatFlowItem): ReactNode => { if (item.kind === 'tool-group') { const inGroup = selectedCallId !== undefined - && item.results.some((r) => r.callId === selectedCallId - || codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true) + && item.results.some(r => r.callId === selectedCallId + || codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true) return ( + return ( + + ) } /* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */ if (node.kind === 'tool-result') return null @@ -282,36 +294,36 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
- {openState === 'loading' &&
载入历史…
} - {openState === 'error' &&
历史加载失败:{openErrorMessage}
} - {hasMore && ( -
- -
- )} - {items.map(renderItem)} - - {runningCalls.length > 0 && ( -
- {runningCalls.map((call) => ( - - ))} -
- )} - {pending.map((item) => )} + {openState === 'loading' &&
载入历史…
} + {openState === 'error' &&
历史加载失败:{openErrorMessage}
} + {hasMore && ( +
+ +
+ )} + {items.map(renderItem)} + + {runningCalls.length > 0 && ( +
+ {runningCalls.map(call => ( + + ))} +
+ )} + {pending.map(item => )}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 7dbefdc139..5cb2126f34 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -30,6 +30,7 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr return ( { + // lib.dom types clipboard non-optional, but insecure contexts omit it — + // that runtime gap is exactly what this guard detects. + /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */ + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + } catch { + // Denied permissions / iframe policy. + } + return + } + // execCommand('copy') is the only clipboard fallback where the async API + // is missing (insecure contexts); deprecated but deliberately retained. + /* eslint-disable @typescript-eslint/no-deprecated */ + const exec = typeof document.execCommand === 'function' + ? document.execCommand.bind(document) + : undefined + if (exec === undefined) return + const el = document.createElement('textarea') + el.value = text + el.setAttribute('readonly', '') + el.style.position = 'fixed' + el.style.left = '-9999px' + document.body.appendChild(el) + el.select() + try { + exec('copy') + } catch { + // Clipboard unavailable; the button stays idle. + } + /* eslint-enable @typescript-eslint/no-deprecated */ + el.remove() +} + /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The @@ -71,20 +111,60 @@ function projectUserText(text: string): ReactNode { return <>{parts} } +/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */ +function UserActions({ text }: { text: string }) { + const onCopy = useCallback(() => { + void writeClipboard(text) + }, [text]) + return ( +
+ + + + + + + + + +
+ ) +} + export const MessageItem = memo(function MessageItem({ node, loadImage = unavailableImage }: MessageItemProps) { switch (node.kind) { - case 'user': + case 'user': { + const { text, images, rest } = contentParts(node.content) + return ( +
+
+ + {(text !== '' || rest.length > 0) &&
+ {projectUserText(text)} + {rest.map((block, i) => )} +
} +
+ +
+ ) + } case 'steering': { const { text, images, rest } = contentParts(node.content) return (
- {(text !== '' || rest.length > 0 || node.kind === 'steering') &&
- {node.kind === 'steering' && 插话} +
+ 插话 {projectUserText(text)} {rest.map((block, i) => )} -
} +
) @@ -92,7 +172,7 @@ export const MessageItem = memo(function MessageItem({ node, loadImage = unavail case 'context': return (
- +
) default: diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 50dead9529..45b783f19b 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -53,7 +53,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals { export interface StatsLineProps { useSession: SnapshotSelectorHook } export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) { - const nodes = useSession((s) => s.nodes) + const nodes = useSession(s => s.nodes) const stats = useMemo(() => deriveStats(nodes), [nodes]) if (stats.steps === 0) return null const parts: string[] = [] diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 16878ae91e..5d44a9260e 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -42,6 +42,21 @@ color: var(--dsw-alias-label-secondary); } +/* Cordis lifecycle tools retain their generic row mechanics while carrying a + shared product accent and tool-owned action title. */ +.root[data-tool^='cordis_'] .leading, +.root[data-tool^='cordis_'] .title { + color: var(--dsw-alias-state-business-primary); +} + +.root[data-tool^='cordis_'] .title { + font-weight: 500; +} + +.root[data-tool^='cordis_'] .sep { + background: var(--dsw-alias-state-business-primary); +} + button.leading { cursor: pointer; } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 113241eb5d..a81c084b8e 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -13,6 +13,8 @@ import css from './ToolRow.module.css' export interface ToolRowProps { variant: ToolRowVariant + /** Wire tool name for tool-owned styling layered over the generic variant. */ + toolName?: string | undefined /** Leading 16px tool icon, shown while collapsed and not running/failed. */ icon: ReactNode title: string @@ -39,6 +41,7 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { export function ToolRow({ variant, + toolName, icon, title, summary, @@ -52,7 +55,7 @@ export function ToolRow({ const open = expanded && expandable const rowExpands = expandable && expandOnRowClick const toggleExpand = () => { - setExpanded((v) => !v) + setExpanded(v => !v) } const toggleFromLeading = (event: MouseEvent) => { event.stopPropagation() @@ -64,7 +67,7 @@ export function ToolRow({ toggleExpand() } return ( -
+
void } /** @@ -184,23 +184,23 @@ export interface ConversationInjected { * Connect the selected Workspace and open its reusable/new blank session. * When a blank session is already current, carry its draft to the target. */ - selectWorkspace(workspaceId: WorkspaceId): void + selectWorkspace: (workspaceId: WorkspaceId) => Promise } /** Business callbacks injected into the strict session content seat. */ export interface ConversationSessionInjected { /** Views projected from the `conversation.view` slot ledger. */ views: { - list(): readonly ViewTab[] - subscribe(fn: () => void): () => void - version(): number + list: () => readonly ViewTab[] + subscribe: (fn: () => void) => () => void + version: () => number } /** Release historical image URLs when this rendered session scope unmounts. */ - releaseSessionImages(sessionId: SessionId): void + releaseSessionImages: (sessionId: SessionId) => void /** Bind the input machine's draft persistence mirror to the session store. */ - bindDraftMirror(write: (text: string) => void): () => void + bindDraftMirror: (write: (text: string) => void) => () => void /** Select a real Session through the runtime navigation owner. */ - open(sessionId: SessionId): void + open: (sessionId: SessionId) => void } /** @@ -230,13 +230,13 @@ export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */ keyboard: ComposerKeyboard /** Create browser previews and append their ids to the session input state. */ - addImages(files: readonly File[], current: readonly ComposerAttachment[]): string | null + addImages: (files: readonly File[], current: readonly ComposerAttachment[]) => string | null /** Release one browser preview and remove its id from the session input state. */ - removeImage(id: string): void + removeImage: (id: string) => void /** Resolve ordered input-state ids to browser-owned draft attachments. */ - draftImages(ids: readonly string[]): readonly ComposerAttachment[] + draftImages: (ids: readonly string[]) => readonly ComposerAttachment[] /** Cancel the in-flight turn. */ - stop(): void + stop: () => void } /** @@ -292,10 +292,10 @@ export type ConversationSessionSlotProps = */ export interface ChatViewInjected { /** Selection write + details panel opening in one gesture (store action + layout orchestration). */ - openDetails(target: SelectionTarget): void - loadOlder(): void + openDetails: (target: SelectionTarget) => void + loadOlder: () => void /** Resolve a session-authorized historical image for inline display. */ - loadImage(attachment: ImageAttachmentRef): Promise + loadImage: (attachment: ImageAttachmentRef) => Promise } /** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */ @@ -309,7 +309,7 @@ export type ChatViewSlotProps = */ export interface DetailsInjected { /** Close the details panel (layout geometry stays with ctx.layout). */ - closeDetails(): void + closeDetails: () => void } /** Full details-slot component props: selection arrives through the shared store, call material through useSession. */ @@ -319,6 +319,6 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & export interface EmptyWorkspaceOwnerProps { open: boolean anchorRef?: RefObject - onPick(workspaceId: WorkspaceId): void - onClose(): void + onPick: (workspaceId: WorkspaceId) => void + onClose: () => void } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 1c5996580f..5b725df00b 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -36,6 +36,16 @@ const TOOL_VARIANTS: Record = { write: 'write', edit: 'edit', run_code: 'code', + cordis_inspect: 'read', + cordis_mount: 'code', + cordis_unmount: 'others', +} + +/** Tool-owned titles that refine a generic row variant without replacing it. */ +const TOOL_TITLES: Record = { + cordis_inspect: 'Inspect', + cordis_mount: 'Mount temporary Plugin', + cordis_unmount: 'Unmount temporary Plugin', } /** @@ -130,12 +140,15 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw) + const toolTitle = TOOL_TITLES[toolName] // Others keeps the static "Tool call" title (figma literal); the real tool - // name rides the mutable summary slot so no information is lost. - const summary = variant === 'others' && toolName !== '' ? `${toolName} · ${base}` : base + // name rides the mutable summary slot unless the tool owns a specific title. + const summary = variant === 'others' && toolName !== '' && toolTitle === undefined + ? `${toolName} · ${base}` + : base return { variant, - title: VARIANT_TITLES[variant], + title: toolTitle ?? VARIANT_TITLES[variant], summary, body: deriveBody(variant, argsRaw), state, diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index fcd7e75732..f5b5047c7b 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -30,7 +30,7 @@ export function QueueDock({ useSession }: QueueDockProps) { } /** - * The dock entry as a plain registrant plugin (bash-sample posture). + * The dock entry as a plain registrant plugin (bash posture). * `inject: ['conversation']` is the ordering seam: the conversation service * mounts after ui-conversation's slot registrations, so the * 'conversation.input.dock' declaration is on the ledger by then. diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 5f9e91a049..d6e7492836 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -2,8 +2,9 @@ // chain stay mounted across no-session/session transitions. Only the inert // input body swaps for the strict session InputBar. -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' +import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import { DisabledInputBar } from './DisabledInputBar.tsx' @@ -25,8 +26,23 @@ export function ConversationRoot({ const workspaces = useWorkspaces(s => s) const [pickerOpen, setPickerOpen] = useState(false) + const [pendingWorkspaceId, setPendingWorkspaceId] = useState() const pickerAnchor = useRef(null) + const sessionWorkspace = sessionId === undefined + ? undefined + : workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId)) + const pendingWorkspace = workspaces.items.find( + workspace => workspace.workspaceId === pendingWorkspaceId, + ) + + useEffect(() => { + if (pendingWorkspaceId !== undefined + && sessionWorkspace?.workspaceId === pendingWorkspaceId) { + setPendingWorkspaceId(undefined) + } + }, [pendingWorkspaceId, sessionWorkspace?.workspaceId]) + const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) const zone: InputZone | undefined = session === undefined || inputState === undefined ? undefined : { session, input: inputState } @@ -36,9 +52,10 @@ export function ConversationRoot({ w.sessionIds.includes(sessionId))?.title ?? workspaceLabel(cwd ?? '') + : sessionWorkspace?.title ?? workspaceLabel(cwd ?? '')) } menuOpen={pickerOpen} onClick={() => { setPickerOpen(open => !open) }} @@ -48,7 +65,10 @@ export function ConversationRoot({ anchorRef: pickerAnchor, onPick: (workspaceId) => { setPickerOpen(false) - selectWorkspace(workspaceId) + setPendingWorkspaceId(workspaceId) + void selectWorkspace(workspaceId).catch(() => { + setPendingWorkspaceId(current => current === workspaceId ? undefined : current) + }) }, onClose: () => { setPickerOpen(false) }, })} @@ -58,12 +78,12 @@ export function ConversationRoot({ const inputBar = sessionId === undefined ? : renderSlot('conversation.composer.bar', { - variant: hero ? 'hero' : 'composer', - ...(hero ? { placeholder: 'Describe what you want to build' } : {}), - overlay: renderSlot('conversation.input.overlay', {}), - leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), - rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), - }) + variant: hero ? 'hero' : 'composer', + ...(hero ? { placeholder: 'Describe what you want to build' } : {}), + overlay: renderSlot('conversation.input.overlay', {}), + leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), + rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone), + }) const composerBar = (
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index bb646987c2..c4ec5d8aa9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -41,8 +41,8 @@ export function ConversationSession({ if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft) const unmirror = bindDraftMirror(actions.setDraft) return () => { unmirror() } - // Mount-only: later store writes come from the machine mirror. - // eslint-disable-next-line react-hooks/exhaustive-deps + // Mount-only (deps pinned to inputActions): later store writes come from + // the machine mirror, not this seed effect. }, [inputActions]) useEffect(() => () => { diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 3d3c84a646..650a95f833 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -86,27 +86,27 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane : material === null ?
该调用不在当前窗口内
: ( - <> - {material.argsRaw !== null && ( -
-
Input
- -
- )} + <> + {material.argsRaw !== null && (
-
Output
- {/* materialFor invariant: result===null ⇔ running (a settled - material always carries its result node). */} - {material.result === null - ?
运行中…
- : ( -
-                            {renderResult(material.result)}
-                          
- )} +
Input
+
- - )} + )} +
+
Output
+ {/* materialFor invariant: result===null ⇔ running (a settled + material always carries its result node). */} + {material.result === null + ?
运行中…
+ : ( +
+                        {renderResult(material.result)}
+                      
+ )} +
+ + )}
) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 7354459c76..3e2be4d4fb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -21,8 +21,9 @@ flex-direction: column; align-items: center; /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by - the chat scroller. Top 8 hosts the error strip's breathing room. */ - padding: 8px 32px 12px; + the chat scroller. Top 6 is the gap under the dock todo strip (12px todo + margin + 6px here); error/status strips still carry their own margin. */ + padding: 6px 32px 12px; } .hero { @@ -393,7 +394,7 @@ .stopping, .stopping:hover { background: var(--dsw-alias-button-primary-dimmed); - color: var(--dsw-alias-brand-text); + color: var(--dsw-alias-label-primary); } .retry { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 81091c5435..7d6ee9b4eb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -32,7 +32,11 @@ export function InputBar({ variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', }: InputBarProps) { const input = useInput(s => s) - const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.getSnapshot) + const noticeStore = keyboard.notices + const notice = useSyncExternalStore( + (fn: () => void) => noticeStore.subscribe(fn), + () => noticeStore.getSnapshot(), + ) const promptError = useSession(s => s.promptError) const running = useSession(s => s.running) const disabled = useSession(s => s.removed) @@ -91,6 +95,8 @@ export function InputBar({ // Shift+Enter is the native newline UNCONDITIONALLY — decided before the // IME guard so a composition-closing Shift+Enter still breaks the line. if (e.key === 'Enter' && e.shiftKey) return + // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. + // eslint-disable-next-line @typescript-eslint/no-deprecated const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229 if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault() @@ -108,7 +114,7 @@ export function InputBar({ // the browser stack cannot represent); never let the native stack run. e.preventDefault() if (machineBusy || locked) return - const redo = e.key === 'y' || (e.shiftKey && (e.key === 'z' || e.key === 'Z')) + const redo = e.key === 'y' || e.shiftKey if (redo) keyboard.redo() else keyboard.undo() return @@ -150,6 +156,8 @@ export function InputBar({ if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock const next = e.target.value keyboard.setDraft(next) + // selectionStart is number|null in lib.dom; the eslint program narrows it. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition keyboard.track(next, e.target.selectionStart ?? next.length) } @@ -161,10 +169,13 @@ export function InputBar({ // too (one char = one step). Mouse selection of a chip is handled in the // backdrop click handler below. Undo/redo must NOT reach the browser: the // machine owns the transaction log. + // selectionStart/End are number|null in lib.dom; the eslint program narrows them. + /* eslint-disable @typescript-eslint/no-unnecessary-condition */ const selectionOf = (el: HTMLTextAreaElement) => ({ start: el.selectionStart ?? 0, end: el.selectionEnd ?? el.selectionStart ?? 0, }) + /* eslint-enable @typescript-eslint/no-unnecessary-condition */ const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { const el = e.currentTarget @@ -426,8 +437,8 @@ export function InputBar({ }} onKeyDown={onKeyDown} onSelect={onSelect} - onCopy={e => { onCopyOrCut(e, false) }} - onCut={e => { onCopyOrCut(e, true) }} + onCopy={(e) => { onCopyOrCut(e, false) }} + onCut={(e) => { onCopyOrCut(e, true) }} onPaste={onPaste} onCompositionStart={onCompositionStart} onCompositionEnd={onCompositionEnd} diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css new file mode 100644 index 0000000000..8086cbfea7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -0,0 +1,116 @@ +/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): + tip surface, 14px radius, status icons + secondary item labels. Column is + calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */ + +.root { + flex: none; + overflow: hidden; + margin: 0 auto; + width: calc(100% - 88px); + max-width: 776px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 14px; + background: var(--dsw-specific-tip); +} + +.body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 16px; +} + +.header { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 0; + border: none; + background: transparent; + text-align: left; + cursor: pointer; +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.progress { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + font-size: 13px; + line-height: 20px; + font-weight: 400; + color: var(--dsw-alias-label-tertiary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + display: grid; + flex: none; + place-items: center; + color: var(--dsw-alias-label-tertiary); +} + +.list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + max-height: 180px; + overflow-y: auto; +} + +.item { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.glyph { + display: grid; + flex: none; + place-items: center; + width: 16px; + height: 16px; +} + +.glyphCompleted { + color: var(--dsw-alias-state-success-primary); +} + +.glyphProgress { + color: var(--dsw-alias-state-business-primary); + animation: todo-progress-spin 1s linear infinite; +} + +.glyphPending { + color: var(--dsw-alias-label-caption); +} + +@keyframes todo-progress-spin { + to { + transform: rotate(360deg); + } +} + +/* Figma strip is single-line; long items ellipsize with no inline expand. */ +.content { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx new file mode 100644 index 0000000000..16edc423c0 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -0,0 +1,140 @@ +// TodoPanel: persistent plan strip above the composer (the web counterpart +// of the TUI plan panel). Renders the latest todo/write whole-list snapshot — +// no data of its own, hidden while the list is empty. Mounted through the +// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does +// the selecting, so the panel takes the plain list and stays framework-free. +// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded). + +import { useId, useState } from 'react' +import type { Context } from 'cordis' +import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' +import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './TodoPanel.module.css' + +export interface TodoPanelProps { + /** The session's current plan (empty renders nothing) — selected by the dock adapter. */ + todos: readonly TodoItem[] +} + +/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if status is forged */ +function assertNever(value: never): never { + throw new Error(`unreachable todo status: ${String(value)}`) +} + +/** Status glyphs share the figma 14×14 artboard; the 16×16 `.glyph` cell centers them. */ +function CompletedGlyph() { + return ( + + ) +} + +/** In-progress: business-blue ring fading out; CSS spins the svg. */ +function ProgressGlyph() { + const gradientId = useId() + return ( + + ) +} + +/** Pending: dashed unstarted ring (figma dash 2.4 2.4). */ +function PendingGlyph() { + return ( + + ) +} + +function StatusGlyph({ status }: { status: TodoItem['status'] }) { + switch (status) { + case 'completed': return + case 'in_progress': return + case 'pending': return + /* v8 ignore next -- closed TodoItem status union */ + default: return assertNever(status) + } +} + +/** Header summary: "/ tasks · in progress". */ +function progressLabel(todos: readonly TodoItem[]): string { + const done = todos.filter(t => t.status === 'completed').length + const active = todos.filter(t => t.status === 'in_progress').length + return `${done}/${todos.length} tasks · ${active} in progress` +} + +export function TodoPanel({ todos }: TodoPanelProps) { + const [collapsed, setCollapsed] = useState(false) + if (todos.length === 0) return null + + return ( +
+
+ + {!collapsed && ( +
    + {todos.map(item => ( +
  • + + {item.content} +
  • + ))} +
+ )} +
+
+ ) +} + +/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ +export type TodoDockProps = PropsRuntime<'conversation.input.dock'> + +/** Dock adapter: selects the plan off the session snapshot and hands the strip a plain list. */ +export function TodoDock({ useSession }: TodoDockProps) { + const todos = useSession(s => s.todos) + return +} + +/** + * The plan strip as a plain registrant plugin (QueueDock posture). + * `inject: ['conversation']` is the ordering seam: the conversation service + * mounts after ui-conversation's slot registrations, so the + * 'conversation.input.dock' declaration is on the ledger by then. + */ +export const todoDockEntry = { + name: 'conversation-todo-dock', + inject: ['slots', 'conversation'], + /** + * Register the plan strip into the input dock (list entry, above the queue rows). + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock) + }, +} diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 83c2329fc5..9c42e69b59 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,29 +1,32 @@ -/* Sample bash rows: deliberately distinct from ToolRow so the differential - registry hit is visible at a glance. */ +/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ -.row { +.root { display: flex; align-items: center; - gap: 8px; height: 24px; min-width: 0; cursor: pointer; border-radius: 6px; - font-family: var(--ds-font-family-code); - font-size: 13px; } -.row:hover { +.root:hover { background: var(--dsw-alias-interactive-bg-hover); } -.prompt { +.leading { flex: none; - color: var(--dsw-alias-state-success-primary); + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); } .scopeBadge { flex: none; + margin-right: 8px; padding: 0 6px; border-radius: 6px; font-size: 11px; @@ -32,17 +35,38 @@ background: var(--dsw-alias-state-business-primary); } -.command { +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-primary-dimmed); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); } -.err { - flex: none; - color: var(--dsw-alias-state-error-primary); - font-size: 11px; +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 9968c3b46e..616eee5943 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -1,35 +1,54 @@ -// Bash toolview sample, written in third-party posture: everything below uses -// only the public slot surface (ctx.slots.register into the keyed -// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof -// that a plain plugin can take over a tool row with zero dedicated machinery. -// Session-dimension differentiation happens INSIDE the component (the -// canonical sub-agent scenario): rows in child sessions render the scoped -// variant, derived from the standard useSessions kit — no registry predicates. +// Bash toolview registrant: third-party posture over the keyed toolview hole +// (ctx.slots.register + ToolRowProps only — never imports the chat domain). +// Product chrome matches ToolRow / Think (figma: Bash · {description}). +// Child sessions keep a scoped badge so session-dimension differentiation stays +// observable inside the component (no parallel registry). import type { Context } from 'cordis' +import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' -import { toolRowModel } from '../contract/tool-call-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import css from './bash-sample.module.css' -/** Bash row: command-first monospace summary replacing the generic card. - * Sub-session rows (parentId present) swap the prompt for a scoped badge — - * the differential stays observable per session from one registration. */ +function leadingFor(state: ToolRowState) { + switch (state) { + case 'running': return + case 'error': return + case 'stopped': return + default: return + } +} + +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) { const model = toolRowModel(toolName, block) const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined) - if (isChild) { - return ( -
- scoped - {model.summary} -
- ) - } + const status = stateStatus(model.state) return ( -
- $ - {model.summary} - {model.state === 'error' && failed} +
+ {leadingFor(model.state)} + {status !== null && {status}} + {isChild && scoped} + {model.title} + + {model.summary}
) } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css new file mode 100644 index 0000000000..ff4068d49c --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -0,0 +1,42 @@ +/* todo_write plan-update row: title + progress summary on one line. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + height: 24px; + min-width: 0; + cursor: pointer; + border-radius: 6px; + font-size: 13px; +} + +.row:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.badge { + flex: none; + color: var(--dsw-alias-state-business-primary); +} + +.title { + flex: none; + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-secondary); +} + +.err { + flex: none; + color: var(--dsw-alias-state-error-primary); + font-size: 11px; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx new file mode 100644 index 0000000000..353e7a5441 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -0,0 +1,93 @@ +// todo_write toolview: plan-flavored summary row replacing the generic +// "Tool call" card, registered into the keyed 'conversation.chat.toolview' +// hole like the bash sample (a product registration, not a sample). The row +// summarizes the written list (counts + active item) from the call args; the +// durable list itself renders in the TodoPanel above the composer, so the +// row stays one line. + +import type { KeyboardEvent } from 'react' +import type { Context } from 'cordis' +import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { toolRowModel } from '../contract/tool-call-model.ts' +import css from './todo-row.module.css' + +/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */ +interface TodoWriteItem { content?: unknown; status?: unknown } + +function isItem(value: unknown): value is TodoWriteItem { + return typeof value === 'object' && value !== null +} + +function summarize(argsRaw: string): string | null { + let parsed: unknown + try { + parsed = JSON.parse(argsRaw) + } catch { + // Mid-stream truncation or malformed model JSON: fall back to the generic summary. + return null + } + // Valid JSON with an invalid shape (null root, non-array todos, null items — + // a rejected tool/call retains such args verbatim): same generic fallback. + if (typeof parsed !== 'object' || parsed === null) return null + const todos = (parsed as { todos?: unknown }).todos + if (!Array.isArray(todos) || !todos.every(isItem)) return null + const done = todos.filter(t => t.status === 'completed').length + const active = todos.find(t => t.status === 'in_progress') + const head = `${done}/${todos.length} 已完成` + return typeof active?.content === 'string' && active.content !== '' + ? `${head} · ${active.content}` + : head +} + +/** One-line plan update row (click opens the raw args in details). Non-ok + * execution states keep the generic row's dot semantics — a cancelled call + * wrote no todo/write, so it must not read as a completed update. */ +export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { + const model = toolRowModel(toolName, block) + const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' + const summary = summarize(argsRaw) ?? model.summary + // Button semantics, not a + + {open && ( + + )} +
+ ) +} diff --git a/packages/client/ui-model/src/client/directory.ts b/packages/client/ui-model/src/client/directory.ts new file mode 100644 index 0000000000..827502d258 --- /dev/null +++ b/packages/client/ui-model/src/client/directory.ts @@ -0,0 +1,126 @@ +/** + * Per-session model directory: the ONE state both selection entries share. + * The /model popup and the composer-seat selector load through the same + * controller and submit through the same selectModel call, so the host stays + * the single fact source and the store is one shared echo — a switch made in + * either entry is what the other shows next. + */ +import type { + IApiClient, ModelCatalogFailure, ModelProviderGroup, ModelTarget, SessionId, SessionModels, +} from '@deepseek-ai/dsh-client-connection/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** Directory snapshot both entries render from. */ +export interface ModelDirectoryState { + /** Target the host reports for the next assembled step; null before the first load. */ + current: ModelTarget | null + /** Successfully loaded provider groups (last good load). */ + groups: readonly ModelProviderGroup[] + /** Provider-local failures from the last load; usable groups stay usable. */ + failures: readonly ModelCatalogFailure[] + /** Lifecycle of the in-flight operation. */ + status: 'idle' | 'loading' | 'ready' | 'selecting' | 'error' + /** Whole-request or selection failure text; null when none. */ + error: string | null +} + +/** One session's shared directory controller; disposed with the session scope. */ +export class ModelDirectory { + /** The shared snapshot both entries render from (uSES-safe store). */ + readonly store: SnapshotStore = createSnapshotStore({ + current: null, groups: [], failures: [], status: 'idle', error: null, + }) + + /** Latest operation wins; an older response never overwrites a newer one. */ + private generation = 0 + private disposed = false + + /** + * @param sessions - the session wire face (captured from the plugin's root connection). + * @param sessionId - the owning session. + */ + constructor( + private readonly sessions: Pick, + private readonly sessionId: SessionId, + ) {} + + /** + * Refresh the advisory directory (both entries call this on open). + * Failure preserves the last good groups and current target. + * @returns the fresh directory value. + */ + async load(): Promise { + const generation = ++this.generation + this.store.update((s) => { s.status = 'loading'; s.error = null }) + const { result } = await this.sessions.models({ sessionId: this.sessionId }) + if (this.disposed || generation !== this.generation) { + if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) + return result.value + } + if (!result.ok) { + this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` }) + throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`) + } + const { current, groups, failures } = result.value + this.store.update((s) => { + s.current = current + s.groups = groups + s.failures = failures + s.status = 'ready' + s.error = null + }) + return result.value + } + + /** + * Select the complete provider/model/reasoning target (both entries submit through here). Success + * updates the shared current; failure surfaces on the store and throws so + * each entry's own retry surface engages. + * @param target - provider, provider-owned model id, and optional adapter-owned effort. + */ + async select(target: ModelTarget): Promise { + const generation = ++this.generation + this.store.update((s) => { s.status = 'selecting'; s.error = null }) + const { result } = await this.sessions.selectModel({ + sessionId: this.sessionId, + provider: target.provider, + model: target.model, + ...target.reasoningEffort === undefined + ? {} + : { reasoningEffort: target.reasoningEffort }, + }) + if (this.disposed || generation !== this.generation) { + if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) + return + } + if (!result.ok) { + this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` }) + throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`) + } + this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null }) + } + + /** + * Drop the previous Host generation's projection and repull it. Clearing + * first prevents an unconsumed process-local selection from being displayed + * while the restarted Host has restored the last logged request target. + */ + resetConnected(): void { + if (this.disposed) return + ++this.generation + this.store.update((s) => { + s.current = null + s.groups = [] + s.failures = [] + s.status = 'idle' + s.error = null + }) + void this.load().catch(() => { /* the next menu open remains the explicit retry surface */ }) + } + + /** Scope teardown: late settlements lose write access to the store. */ + dispose(): void { + this.disposed = true + } +} diff --git a/packages/client/ui-model/src/client/index.ts b/packages/client/ui-model/src/client/index.ts new file mode 100644 index 0000000000..a2bef2a5b6 --- /dev/null +++ b/packages/client/ui-model/src/client/index.ts @@ -0,0 +1,130 @@ +/** + * Model selection plugin, browser half — TWO entries over ONE per-session + * directory owned by ModelService (`ctx.models`). The /model popupSelect + * contribution and the composer's named `conversation.input.model` seat both + * load the session's provider-grouped advisory directory (`session.models`) + * and submit through `session.selectModel` via the same directory instance, + * so the host-reported current target is the single fact both surfaces echo + * — a switch made in either entry is what the other shows next. Failures + * ride each entry's own retry surface (popup shell error/retry; seat menu + * inline error) without forking the state. + */ +import type { ModelTarget, SessionModels } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' +// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat). +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ModelDirectoryState } from './directory.ts' +import { ModelService } from './service.ts' +import type { ModelSelectInjected } from './slots.ts' +import { ModelSelect } from './ModelSelect.tsx' + +export { ModelDirectory } from './directory.ts' +export type { ModelDirectoryState } from './directory.ts' +export { ModelService } from './service.ts' +export type { ModelSelectInjected } from './slots.ts' + +/** One selectable row's id: an opaque row key (resolved by lookup, never parsed). */ +function rowId(providerId: string, modelId: string): string { + return `${providerId}/${modelId}` +} + +/** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */ +function optionsOf(directory: SessionModels): SelectOption[] { + const rows: SelectOption[] = [] + for (const group of directory.groups) { + for (const model of group.models) { + rows.push({ + id: rowId(group.id, model.id), + label: model.name, + detail: model.unlisted === true + ? `${group.name} · 未列入目录` + : model.description !== undefined ? `${group.name} · ${model.description}` : group.name, + ...(directory.current.provider === group.id && directory.current.model === model.id + ? { active: true } : {}), + }) + } + } + for (const failure of directory.failures) { + rows.push({ id: `failure/${failure.id}`, label: failure.name, detail: `目录加载失败:${failure.message}` }) + } + return rows +} + +/** + * Resolve a picked row back to its target by matching against the loaded + * groups (the same data the rows were built from — ids stay opaque). + * @param state - the session's directory snapshot. + * @param id - the picked row id. + * @returns the row's target, or undefined for failure rows / stale ids. + */ +function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefined { + for (const group of state.groups) { + for (const model of group.models) { + if (rowId(group.id, model.id) !== id) continue + const sameRoute = state.current?.provider === group.id && state.current.model === model.id + const reasoningEffort = sameRoute + ? state.current?.reasoningEffort ?? model.reasoning?.defaultEffort + : model.reasoning?.defaultEffort + return { + provider: group.id, + model: model.id, + ...reasoningEffort === undefined ? {} : { reasoningEffort }, + } + } + } + return undefined +} + +/** Required services: the contribution registry, the seat's slot registry, and the service's own faces. */ +export const inject = ['command', 'connection', 'sessions', 'slots'] + +/** + * Client plugin body: mount ModelService, then register the /model popup + * contribution and the composer model seat over it. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.plugin(ModelService) + + // Entry 1: the /model popupSelect over the shared directory. + ctx.inject(['command', 'models'], (scope: ClientContext) => { + const command = scope.get('command') as CommandServiceContract + const models = scope.models + scope.effect(() => command.register({ + name: 'model', + description: 'Select the model for this conversation', + available: () => true, + ui: { + kind: 'popupSelect', + options: async session => optionsOf(await models.directoryFor(session.sessionId).load()), + onSelect: async (option, session) => { + const directory = models.directoryFor(session.sessionId) + const target = targetOf(directory.store.getSnapshot(), option.id) + if (target === undefined) { + throw new Error('this provider\'s catalog failed to load — pick a model from a loaded group') + } + await directory.select(target) + }, + }, + }), 'ui-model: /model contribution') + }) + + // Entry 2: the composer's named model seat over the SAME directory. + // Conditional mount: the seat is declared by the composer-bar entry; the + // conversation service's presence is the registration-safe signal. + ctx.inject(['slots', 'conversation', 'models'], (scope: ClientContext) => { + const models = scope.models + scope.effect(() => scope.slots.register({ + name: 'conversation.input.model', + inject: (sessionId): ModelSelectInjected => { + const directory = models.directoryFor(sessionId) + return { + directory: directory.store, + load: () => { directory.load().catch(() => { /* surfaced on the store */ }) }, + select: (target: ModelTarget) => directory.select(target).then(() => true, () => false), + } + }, + }, ModelSelect), 'ui-model: composer model seat registration') + }) +} diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts new file mode 100644 index 0000000000..cabeaf52e6 --- /dev/null +++ b/packages/client/ui-model/src/client/service.ts @@ -0,0 +1,71 @@ +/** + * ModelService (`ctx.models`): the root owner of per-session + * {@link ModelDirectory} instances. Both selection entries (the /model popup + * and the composer model seat) resolve their session's directory through + * this service, which is what makes the dual entry one shared state. + * + * Per-session storage follows the client service pattern (SlashService / + * CommandService): a lazy service-internal map whose entry is deleted by the + * owning scope's disposer. The host `dsh-scope` ScopedLayers registry does + * not transplant here: it derives scope from the host carrier mechanism + * (object-keyed), while client scopes tag contexts with branded SessionId + * strings, and it models global+shadow named registries — this is a + * per-session singleton with no global layer to merge. + */ +import { Service } from 'cordis' +import type { Context } from 'cordis' +import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { ModelDirectory } from './directory.ts' + +declare module 'cordis' { + interface Context { + models: ModelService + } +} + +/** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */ +interface LiveState { + /** Per-session directories; entries are deleted by their scope disposer. */ + readonly directories: Map +} + +/** The `ctx.models` session model-selection service. */ +export class ModelService extends Service { + static inject = ['connection', 'sessions'] + + private readonly live: LiveState = { directories: new Map() } + + /** + * @param ctx - owning root context (the service registers itself as `models`). + */ + constructor(ctx: Context) { + super(ctx, 'models') + ctx.on('connection/reset', () => { + for (const directory of this.live.directories.values()) directory.resetConnected() + }) + } + + /** + * Resolve the per-session shared directory (lazy; the scope disposer + * removes and disposes it). Unknown sessions fail loud. + * @param sessionId - the owning session. + * @returns the resident directory both entries share. + */ + directoryFor(sessionId: SessionId): ModelDirectory { + const { live } = this + const existing = live.directories.get(sessionId) + if (existing !== undefined) return existing + const sessions = this.ctx.get('sessions') as SessionsService + const actx = sessions.scope(sessionId) + if (actx === undefined) throw new Error(`ui-model: session "${String(sessionId)}" resolved no scope`) + const connection = this.ctx.get('connection') as ConnectionHandle + const directory = new ModelDirectory(connection.api.sessions, sessionId) + live.directories.set(sessionId, directory) + actx.effect(() => () => { + directory.dispose() + live.directories.delete(sessionId) + }, 'ui-model: session directory') + return directory + } +} diff --git a/packages/client/ui-model/src/client/slots.ts b/packages/client/ui-model/src/client/slots.ts new file mode 100644 index 0000000000..d508026f78 --- /dev/null +++ b/packages/client/ui-model/src/client/slots.ts @@ -0,0 +1,23 @@ +/** + * ModelSelect's injected face. The target 'conversation.input.model' seat is + * declared (children table) and typed by ui-conversation's composer-bar + * entry; this package only contributes the single occupant, so no SlotMap + * merge lives here. + */ +import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { ModelDirectoryState } from './directory.ts' + +/** Injected business face of the composer model seat. */ +export interface ModelSelectInjected { + /** The session's shared directory store (same instance the /model popup reads). */ + directory: SnapshotStore + /** Refresh the advisory directory (fire-and-forget; errors land on the store). */ + load: () => void + /** + * Select a complete provider/model/reasoning target through the shared route. + * @param target - model target and optional adapter-owned effort. + * @returns whether the host accepted the selection. + */ + select: (target: ModelTarget) => Promise +} diff --git a/packages/client/ui-model/src/css-modules.d.ts b/packages/client/ui-model/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-model/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-model/src/index.ts b/packages/client/ui-model/src/index.ts new file mode 100644 index 0000000000..83daadbf12 --- /dev/null +++ b/packages/client/ui-model/src/index.ts @@ -0,0 +1,9 @@ +/** + * Model selection plugin, node half. Pure UI plugin: the empty apply exists + * so the plugin appears in the host cordis.yml / Loader; the browser half + * ships via exports["./client"], discovered through the package.json + * dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-model/src/invariant.ts b/packages/client/ui-model/src/invariant.ts new file mode 100644 index 0000000000..baac6dcc70 --- /dev/null +++ b/packages/client/ui-model/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-model`. + * @module @deepseek-ai/dsh-client-ui-model/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-model' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-model-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a single command contribution registration whose disposal is + * proven by the HMR-safety spec — it emits no cordis events and owns no + * cross-plugin mutable state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..8caa7d097f --- /dev/null +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -0,0 +1,210 @@ +/** + * ui-model browser half on a real cordis Context with fake command/slots/ + * connection faces and real session scopes: the plugin mounts ModelService + * as `models`, the /model contribution and the conversation.input.model + * seat both register, and BOTH entries resolve the SAME per-session + * directory through the service — a selection submitted through the seat's + * inject face is the current the popup's next options pass marks active + * (and the reverse), the one-shared-state contract of the dual entry. + * Scope disposal drops the directory (HMR safety). + */ +import { Context } from 'cordis' +import { describe, expect, it } from 'vitest' +import { createScope } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' +import type { ModelSelectInjected } from '../src/client/slots.ts' +import { apply, inject } from '../src/client/index.ts' + +const sid = (k: string): SessionId => k as SessionId + +const GROUPS = [{ + id: 'deepseek', + name: 'DeepSeek', + models: [ + { + id: 'deepseek-v4-flash', + name: 'DeepSeek-V4-Flash', + reasoning: { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max' }, + ], + defaultEffort: 'high', + }, + }, + { + id: 'deepseek-v4-pro', + name: 'DeepSeek-V4-Pro', + reasoning: { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max' }, + ], + defaultEffort: 'high', + }, + }, + ], +}] + +/** Boot the plugin over fake faces + a stateful fake host (current moves on selectModel). */ +async function bench() { + const ctx = new Context() + let current: ModelTarget = { provider: 'deepseek', model: 'deepseek-v4-flash' } + const calls = { models: 0, select: 0 } + ctx.provide('connection', { api: { sessions: { + models: () => { + calls.models += 1 + return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } }) + }, + selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => { + calls.select += 1 + current = { + provider: payload.provider, + model: payload.model, + ...payload.reasoningEffort === undefined + ? {} + : { reasoningEffort: payload.reasoningEffort }, + } + return Promise.resolve({ result: { ok: true as const, value: { selected: current } } }) + }, + } } }) + let contribution: CommandContribution | undefined + ctx.provide('command', { + register(c: CommandContribution) { + contribution = c + return () => { contribution = undefined } + }, + }) + const seats = new Map ModelSelectInjected) | undefined }>() + ctx.provide('slots', { + register(options: { name: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) { + seats.set(options.name, { inject: options.inject }) + return () => { seats.delete(options.name) } + }, + }) + ctx.provide('conversation', {}) + const scopes = new Map() + ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) }) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + await ctx.plugin(function probe() {}).await() + const mint = (key: string) => { + const handle = createScope(ctx, sid(key)) + scopes.set(sid(key), handle.ctx) + return handle + } + return { + ctx, fiber, mint, calls, + contribution: () => contribution!, + seat: () => seats.get('conversation.input.model')!, + hostCurrent: () => current, + setHostCurrent: (target: ModelTarget) => { current = target }, + } +} + +const projection = (id: string) => ({ sessionId: sid(id) }) + +describe('ui-model dual entry', () => { + it('registers the /model contribution and the composer model seat', async () => { + const b = await bench() + expect(b.contribution().name).toBe('model') + expect(b.contribution().ui.kind).toBe('popupSelect') + expect(b.seat().inject).toBeTypeOf('function') + }) + + it('popup options mark the host current active with the provider group in the detail', async () => { + const b = await bench() + b.mint('s1') + const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) + expect(options.map((o: SelectOption) => o.label)).toEqual(['DeepSeek-V4-Flash', 'DeepSeek-V4-Pro']) + expect(options[0]).toMatchObject({ active: true, detail: 'DeepSeek' }) + expect(options[1]?.active).toBeUndefined() + }) + + it('a seat selection is the current the popup marks active next — one shared state', async () => { + const b = await bench() + b.mint('s1') + const seatFace = b.seat().inject!(sid('s1')) + // Switch through the SEAT entry. + expect(await seatFace.select({ + provider: 'deepseek', + model: 'deepseek-v4-pro', + reasoningEffort: 'max', + })).toBe(true) + expect(b.hostCurrent()).toEqual({ + provider: 'deepseek', + model: 'deepseek-v4-pro', + reasoningEffort: 'max', + }) + expect(seatFace.directory.getSnapshot().current).toEqual({ + provider: 'deepseek', + model: 'deepseek-v4-pro', + reasoningEffort: 'max', + }) + // The POPUP's next options pass reflects it without a seat-side reload. + const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) + expect(options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')).toMatchObject({ active: true }) + }) + + it('a popup selection lands on the seat store — the reverse direction of the same state', async () => { + const b = await bench() + b.mint('s1') + const seatFace = b.seat().inject!(sid('s1')) + const options = await b.contribution().ui.options(projection('s1'), new AbortController().signal) + const pro = options.find((o: SelectOption) => o.label === 'DeepSeek-V4-Pro')! + await b.contribution().ui.onSelect(pro, projection('s1')) + expect(seatFace.directory.getSnapshot().current).toEqual({ + provider: 'deepseek', + model: 'deepseek-v4-pro', + reasoningEffort: 'high', + }) + }) + + it('both entries share one directory instance per session, isolated across sessions', async () => { + const b = await bench() + b.mint('a') + b.mint('b') + const faceA = b.seat().inject!(sid('a')) + const faceA2 = b.seat().inject!(sid('a')) + const faceB = b.seat().inject!(sid('b')) + expect(faceA.directory).toBe(faceA2.directory) + expect(faceA.directory).not.toBe(faceB.directory) + // The service face resolves the same instance the seat inject handed out. + expect(b.ctx.models.directoryFor(sid('a')).store).toBe(faceA.directory) + }) + + it('drops an unconsumed local selection and restores the Host target after reconnect', async () => { + const b = await bench() + b.mint('s1') + const face = b.seat().inject!(sid('s1')) + await face.select({ provider: 'deepseek', model: 'deepseek-v4-pro' }) + b.setHostCurrent({ provider: 'deepseek', model: 'deepseek-v4-flash' }) + + b.ctx.emit('connection/reset') + expect(face.directory.getSnapshot()).toMatchObject({ current: null, status: 'loading' }) + await Promise.resolve() + expect(face.directory.getSnapshot()).toMatchObject({ + current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + status: 'ready', + }) + }) + + it('scope disposal drops the directory; a reborn scope gets a fresh one', async () => { + const b = await bench() + const first = b.mint('s1') + const face1 = b.seat().inject!(sid('s1')) + await first.fiber.dispose() + b.mint('s1') + const face2 = b.seat().inject!(sid('s1')) + expect(face2.directory).not.toBe(face1.directory) + }) + + it('an unknown session fails loud at the seat inject', async () => { + const b = await bench() + expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/) + }) +}) diff --git a/packages/client/ui-model/tests/model-select.spec.tsx b/packages/client/ui-model/tests/model-select.spec.tsx new file mode 100644 index 0000000000..dd24b2153e --- /dev/null +++ b/packages/client/ui-model/tests/model-select.spec.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { ModelDirectoryState } from '../src/client/directory.ts' +import { ModelSelect } from '../src/client/ModelSelect.tsx' + +const reasoning = { + efforts: [ + { id: 'off', name: 'Off' }, + { id: 'high', name: 'High' }, + { id: 'max', name: 'Max', description: 'Largest budget' }, + ], + defaultEffort: 'high', +} + +function state(overrides: Partial = {}): ModelDirectoryState { + return { + current: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + groups: [{ + id: 'deepseek', + name: 'DeepSeek', + models: [{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', reasoning }], + }], + failures: [], + status: 'ready', + error: null, + ...overrides, + } +} + +afterEach(cleanup) + +describe('ModelSelect reasoning effort', () => { + it('renders adapter metadata and submits the effort as part of the session target', async () => { + const directory = createSnapshotStore(state()) + const select = vi.fn(async (target: ModelTarget) => { + directory.update((snapshot) => { snapshot.current = target }) + return true + }) + render() + + const trigger = screen.getByRole('button', { + name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High', + }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ })) + expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)) + .toEqual(['Off', 'High', 'MaxLargest budget']) + + fireEvent.click(screen.getByRole('menuitemradio', { name: /Max/ })) + await waitFor(() => { + expect(select).toHaveBeenCalledWith({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + reasoningEffort: 'max', + }) + expect(trigger.getAttribute('aria-label')).toBe('选择模型,当前 DeepSeek-V4-Flash,推理等级 Max') + }) + }) + + it('offers provider default only when the adapter does not configure a model default', () => { + const directory = createSnapshotStore(state({ + groups: [{ + id: 'provider', + name: 'Provider', + models: [{ + id: 'model', + name: 'Model', + reasoning: { efforts: [{ id: 'standard', name: 'Standard' }] }, + }], + }], + current: { provider: 'provider', model: 'model' }, + })) + render() + + fireEvent.click(screen.getByRole('button', { + name: '选择模型,当前 Model,推理等级 Provider default', + })) + fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ })) + expect(screen.getAllByRole('menuitemradio').map(item => item.textContent)) + .toEqual(['Provider default', 'Standard']) + }) +}) diff --git a/packages/client/ui-model/tsconfig.json b/packages/client/ui-model/tsconfig.json new file mode 100644 index 0000000000..5e6c9a35ac --- /dev/null +++ b/packages/client/ui-model/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../connection" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-command" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slash" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-model/tsdown.config.ts b/packages/client/ui-model/tsdown.config.ts new file mode 100644 index 0000000000..79905da095 --- /dev/null +++ b/packages/client/ui-model/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-model', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6b4e776cfe..6162494def 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 4e2a22e77dc1611728477ea0a9d8c50dfc9f7f5d -README.zh.md: 36253971281fd346f9b0ec4648c4b8824ed918a7 +README.md: 58e450451ab64f69762817dfb277b8a888e2177f +README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 4e2a22e77d..58e450451a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. +`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Model Experience diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 3625397128..6824f3efe4 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -6,7 +6,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 模型体验 diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index 642372868a..d2e39dbf23 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -19,7 +19,7 @@ export function Button({ variant = 'ghost', size = 'md', icon, className, childr variant?: ButtonVariant size?: 'md' | 'sm' icon?: ReactNode - className?: string + className?: string | undefined children?: ReactNode } & ButtonHTMLAttributes) { return ( diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 9abb6a3bb2..6ed7f6c0c2 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -158,63 +158,63 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align // (open/toggle) after onSelect. onClick={(e) => { e.stopPropagation() }} > - {items.map(entry => { - if (isSeparator(entry)) { - return
- } - if (isLabel(entry)) { - return
{entry.text}
- } - const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 - const subOpen = hasSub && openSubmenuId === entry.id - return ( -
{ setOpenSubmenuId(hasSub ? entry.id : null) }} - onMouseLeave={() => { setOpenSubmenuId(null) }} - > - - {subOpen && entry.submenu !== undefined && ( -
- {entry.submenu.map(sub => ( - - ))} -
- )} + {items.map((entry) => { + if (isSeparator(entry)) { + return
+ } + if (isLabel(entry)) { + return
{entry.text}
+ } + const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 + const subOpen = hasSub && openSubmenuId === entry.id + return ( +
{ setOpenSubmenuId(hasSub ? entry.id : null) }} + onMouseLeave={() => { setOpenSubmenuId(null) }} + > + + {subOpen && entry.submenu !== undefined && ( +
+ {entry.submenu.map(sub => ( + + ))}
- ) - })} + )} +
+ ) + })}
) diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index e2a49f6579..2c48854055 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -27,7 +27,8 @@ interface AnchorProps { * Attach a hover/focus tooltip to an anchor element. * @param props.label - bubble text. * @param props.side - placement relative to the anchor (default 'right'). - * @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions). + * @param props.disabled - suppress the bubble while true; the anchor renders identically so + * toggling never remounts it (which would cut its CSS transitions). * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused. */ diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4c2083bae1..46bf94c865 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -544,14 +544,14 @@ export const IconApiOutline14 = ({ size = 14, className }: IconProps) => ( - + ) /** ic_ds_personalization_outline_16 (figma extract) */ export const IconPersonalizationOutline16 = ({ size = 16, className }: IconProps) => ( - + ) /** ic_ds_project_add_outline_16 (figma extract) */ @@ -559,7 +559,7 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) => - + ) /** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */ @@ -567,14 +567,14 @@ export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => ( - + ) /** folder_close_16 (figma extract) */ export const IconFolderClose16 = ({ size = 16, className }: IconProps) => ( - + ) /** tree_corner_8x10 (figma extract; session-tree "L" connector, stroke geometry pre-expanded) */ diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css index f9b5f67136..7222c3df44 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css @@ -1,13 +1,79 @@ -/* One code-block geometry for highlighted and plain arms: the shiki
-   and the fallback 
 draw identically except for token colors. */
+/* Visual baseline: deepsuite `@deepseek/md` code-block.css. Highlight colors
+   stay on the existing shiki `--shiki-*` sheet (not Prism highlight.css). */
+
+.block {
+  --dsl-code-block-banner-background-color: var(--dsw-alias-markdown-code-block-banner);
+  --dsl-code-block-border-radius: 12px;
+  --dsl-code-block-banner-font: var(--dsw-font-xs-13);
+  --dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
+
+  position: relative;
+  margin: 16px 0;
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-markdown-code-block);
+  border-radius: var(--dsl-code-block-border-radius);
+}
+
+.block:not(:last-child) {
+  margin-bottom: 11px;
+}
+
+.bannerWrap {
+  position: sticky;
+  top: 0;
+  z-index: 6;
+  background-color: var(--dsw-alias-bg-base);
+  border-top-left-radius: var(--dsl-code-block-border-radius);
+  border-top-right-radius: var(--dsl-code-block-border-radius);
+}
+
+.banner {
+  background: var(--dsl-code-block-banner-background-color);
+  padding: 9px 14px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 12px;
+  font: var(--dsl-code-block-banner-font);
+  border-top-left-radius: var(--dsl-code-block-border-radius);
+  border-top-right-radius: var(--dsl-code-block-border-radius);
+}
+
+.infostring {
+  color: var(--dsw-alias-label-primary);
+  font-family: var(--ds-font-family-code);
+  font-size: 12px;
+  line-height: 18px;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.action {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+}
+
+.copyButton {
+  background-color: rgb(255 255 255 / 0);
+  border: none;
+  padding: 0;
+  margin: 0;
+  color: inherit;
+  cursor: pointer;
+  font: inherit;
+}
 
 .block :where(pre) {
-  margin: 0;
-  padding: 8px 10px;
-  border-radius: 8px;
+  font: var(--dsl-code-block-content-font);
+  padding: 16px;
+  margin: 0 !important;
   overflow-x: auto;
+  white-space: pre-wrap;
+  word-break: break-all;
   background: var(--dsw-alias-markdown-code-block);
-  font: var(--dsw-font-markdown-code-block);
 }
 
 /* Shiki inlines its theme background var; route it to the repo token. */
@@ -23,5 +89,4 @@
 
 .plain {
   color: var(--dsw-alias-label-primary);
-  white-space: pre;
 }
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
index 1a6349f1e8..de6a478af4 100644
--- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -1,12 +1,10 @@
 // CodeBlock: one code surface for every consumer — markdown fences, the
 // run_code program body, and the details panel's raw args/output — with
 // shiki highlighting for the registered grammars and an identical-geometry
-// plain fallback for everything else. Shiki emits a single 
-// tree of nested spans whose colors are --shiki-* custom properties
-// (token sheets own the values); it produces no scripts or event handlers,
-// so injecting its output is safe by construction.
+// plain fallback for everything else. Chrome (language banner + copy) matches
+// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
 
-import { useMemo } from 'react'
+import { useCallback, useMemo, useRef, useState } from 'react'
 import clsx from 'clsx'
 import { highlightToHtml } from './highlight.ts'
 import css from './CodeBlock.module.css'
@@ -20,18 +18,87 @@ export interface CodeBlockProps {
   className?: string | undefined
 }
 
+/** @returns true only when the host accepted the write. */
+async function writeClipboard(text: string): Promise {
+  // lib.dom types clipboard non-optional, but insecure contexts omit it —
+  // that runtime gap is exactly what this guard detects.
+  /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
+  if (navigator.clipboard?.writeText) {
+    try {
+      await navigator.clipboard.writeText(text)
+      return true
+    } catch {
+      // Denied permissions / iframe policy — do not claim success.
+      return false
+    }
+  }
+  // jsdom and older hosts: best-effort execCommand path when present.
+  // execCommand('copy') is the only clipboard fallback where the async API
+  // is missing; deprecated but deliberately retained.
+  /* eslint-disable @typescript-eslint/no-deprecated */
+  const exec = typeof document.execCommand === 'function'
+    ? document.execCommand.bind(document)
+    : undefined
+  if (exec === undefined) return false
+  const el = document.createElement('textarea')
+  el.value = text
+  el.setAttribute('readonly', '')
+  el.style.position = 'fixed'
+  el.style.left = '-9999px'
+  document.body.appendChild(el)
+  el.select()
+  try {
+    return exec('copy')
+  } catch {
+    return false
+  } finally {
+    el.remove()
+  }
+  /* eslint-enable @typescript-eslint/no-deprecated */
+}
+
 export function CodeBlock({ code, lang, className }: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
-  if (html === undefined) {
-    return (
-      
-
{trimmed}
-
+ const rootRef = useRef(null) + const [copied, setCopied] = useState(false) + + const onCopy = useCallback(() => { + if (copied) return + /* v8 ignore next -- both arms always mount a
; trimmed is the
+       typed fallback if the DOM shape ever diverges. */
+    const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
+    void writeClipboard(text).then((ok) => {
+      if (!ok) return
+      setCopied(true)
+      window.setTimeout(() => { setCopied(false) }, 1000)
+    })
+  }, [copied, trimmed])
+
+  const body = html === undefined
+    ? (
+      
{trimmed}
) - } - // eslint-disable-next-line react/no-danger -- shiki's output is a static - // span tree it generated from `code` (no user HTML passes through), the - // sanctioned innerHTML consumption path per shiki's own docs. - return
+ : ( + // shiki's output is a static span tree it generated from `code` (no user + // HTML passes through), the sanctioned innerHTML consumption path per + // shiki's own docs. +
+ ) + + return ( +
+
+
+
{lang ?? ''}
+
+ +
+
+
+ {body} +
+ ) } diff --git a/packages/client/ui-primitives/src/markdown/JsonBlock.tsx b/packages/client/ui-primitives/src/markdown/JsonBlock.tsx index c916c8303d..ecbf594261 100644 --- a/packages/client/ui-primitives/src/markdown/JsonBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/JsonBlock.tsx @@ -15,6 +15,8 @@ export function JsonBlock({ label, payload, defaultOpen = false }: { if (!open) return '' let s: string try { + // lib typing hides stringify's undefined arm (undefined/function/symbol payloads). + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition s = JSON.stringify(payload, null, 2) ?? String(payload) } catch { s = String(payload) @@ -23,7 +25,7 @@ export function JsonBlock({ label, payload, defaultOpen = false }: { }, [open, payload]) return (
- {open &&
{body}
} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 36b1dc2b55..a189528bc9 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -1,95 +1,168 @@ +/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS + Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are + intentionally absent (no matching DOM). Token names match that sheet. */ + .markdown { - display: flex; min-width: 0; - flex-direction: column; - gap: 12px; overflow-wrap: anywhere; font: var(--dsw-font-markdown-base); + color: var(--dsw-alias-label-primary); } -.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) { - margin: 0; +.markdown strong { + font-weight: 600; } .markdown h1 { font: var(--dsw-font-markdown-h1); + margin: 32px 0 16px; } .markdown h2 { font: var(--dsw-font-markdown-h2); + margin: 32px 0 16px; } .markdown h3 { font: var(--dsw-font-markdown-h3); + margin: 32px 0 16px; } -.markdown :where(h4, h5, h6) { +.markdown h4 { font: var(--dsw-font-markdown-h4); + margin: 16px 0; } -.markdown :where(strong, th) { - font-weight: var(--dsw-font-markdown-base-strong-font-weight); +.markdown :where(h5, h6) { + font: var(--dsw-font-markdown-base-strong); + margin: 16px 0; } -.markdown :where(ul, ol) { - padding-inline-start: 24px; +.markdown :where(h1, h2, h3, h4, h5, h6) strong { + font-weight: inherit; } -.markdown li + li { - margin-block-start: 4px; +.markdown p { + margin: 16px 0; } -.markdown li > :where(ul, ol) { - margin-block-start: 4px; +/* Tighten h4–h6 against a following list (design: 8px gap). */ +.markdown :where(h4, h5, h6) + :where(ul, ol) { + margin-top: 8px; } -.markdown blockquote { - padding-inline-start: 12px; - border-inline-start: 3px solid var(--dsw-alias-markdown-citation); - color: var(--dsw-alias-label-secondary); +.markdown :where(h4, h5, h6):has(+ :where(ul, ol)) { + margin-bottom: 8px; } .markdown a { + /* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet + keeps design-platform brand-text as near-black, so links use the blue + business-primary alias instead. */ color: var(--dsw-alias-state-business-primary); - text-decoration: underline; - text-underline-offset: 2px; + transition: box-shadow var(--ds-transition-duration) var(--ds-ease-in-out); + position: relative; + text-decoration: none; + /* Transparent hit-area padding; literal zero-alpha only (no painted color). */ + border-left: 3px solid rgb(255 255 255 / 0); + border-right: 3px solid rgb(255 255 255 / 0); + border-top: 2px solid rgb(255 255 255 / 0); + border-bottom: 2px solid rgb(255 255 255 / 0); + margin-left: -3px; + margin-right: -3px; } -.markdown :not(pre) > code { - padding: 2px 4px; - border-radius: 4px; - background: var(--dsw-alias-markdown-inline-code); - font: var(--dsw-font-markdown-code); +.markdown a:hover, +.markdown a:focus { + outline: none; + text-decoration: underline var(--dsw-alias-state-business-primary); } -.markdown pre { - max-width: 100%; - overflow-x: auto; - overscroll-behavior-x: contain; - padding: 12px 16px; - border-radius: 8px; - background: var(--dsw-alias-markdown-code-block); - font: var(--dsw-font-markdown-code-block); +.markdown a:focus-visible { + box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); } -.markdown pre code { - padding: 0; - background: transparent; - font: inherit; - overflow-wrap: normal; - word-break: normal; - white-space: pre; +.markdown :where(ul, ol) { + margin: 16px 0; + padding-left: 18px; +} + +.markdown li:not(:first-child) { + margin-top: 6px; +} + +.markdown li > :where(ul, ol) { + margin-top: 4px; +} + +.markdown li::marker { + line-height: 28px; + color: var(--dsw-alias-label-secondary); +} + +/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */ +.markdown :where(ul, ol) ol { + list-style-position: inside; + padding-left: 0; +} + +.markdown :where(ul, ol) ol li p { + display: inline; +} + +.markdown li > p { + margin: 8px 0; +} + +.markdown li > *:first-child { + margin-top: 0; +} + +/* Keep list-nested code-block vertical margins (design: +4px vs other last children). */ +.markdown li > *:last-child:not(:global(.md-code-block)) { + margin-bottom: 0; } .markdown hr { - width: 100%; - border: 0; - border-block-start: 1px solid var(--dsw-alias-markdown-citation); + display: block; + border: none; + height: 1px; + margin: 32px 0; + background: var(--dsw-alias-border-l2); +} + +.markdown blockquote { + border-left: 2px solid var(--dsw-alias-label-caption); + margin: 16px 0 0; + padding-left: 14px; +} + +.markdown pre { + margin: 16px 0; + font-family: var(--ds-font-family-code); + overflow: auto; +} + +.markdown :not(pre) > code { + display: inline-flex; + align-items: center; + box-sizing: border-box; + font: var(--dsw-font-markdown-code); + font-family: var(--ds-font-family-code); + font-size: 0.875em !important; + background-color: var(--dsw-alias-markdown-inline-code); + border-radius: 6px; + padding: 0 5px; +} + +.markdown :where(h1, h2, h3, h4, h5, h6) code { + font: inherit; + font-family: var(--ds-font-family-code); } .markdown input[type='checkbox'] { margin: 0 8px 0 0; - accent-color: var(--dsw-alias-state-business-primary); + accent-color: var(--dsw-alias-label-secondary); } .tableScroll { @@ -99,22 +172,52 @@ } .tableScroll table { - width: max-content; - min-width: 100%; border-collapse: collapse; - font: var(--dsw-font-markdown-table); -} - -.tableScroll :where(th, td) { - padding: 6px 12px; - border: 1px solid var(--dsw-alias-markdown-citation); - text-align: start; - white-space: nowrap; + width: max-content; + max-width: max-content; } .tableScroll th { - background: var(--dsw-alias-markdown-code-block-banner); + text-align: start; + padding: 10px 16px; + border-bottom: 1px solid var(--dsw-alias-border-l3); + border-top: none; font: var(--dsw-font-markdown-table-head); + max-width: 320px; + max-width: min(30vw, 320px); + min-width: 100px; +} + +.tableScroll td { + padding: 10px 16px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + font: var(--dsw-font-markdown-table); + max-width: 320px; + max-width: min(30vw, 320px); + min-width: 100px; +} + +.tableScroll th:first-child, +.tableScroll td:first-child { + padding-left: 0; +} + +.tableScroll td:last-child { + padding-right: 0; +} + +.tableScroll table code { + font-size: 13px; +} + +.markdown > *:first-child, +.markdown p:first-child { + margin-top: 0 !important; +} + +.markdown > *:last-child, +.markdown p:last-child { + margin-bottom: 0 !important; } .imageAlt { diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 775978a275..639f53dbb1 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -27,25 +27,25 @@ const safeUrl: UrlTransform = url => sanitizeUrl(url) /** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ function buildComponents(streaming: boolean): Components { return { - a: ({ href = '', children }) => { - const safeHref = sanitizeUrl(href) - if (safeHref === '') return <>{children} - const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) - return ( - - {children} - - ) - }, - img: ({ alt = '' }) => {alt}, - table: ({ children }) => ( -
- {children}
-
- ), + a: ({ href = '', children }) => { + const safeHref = sanitizeUrl(href) + if (safeHref === '') return <>{children} + const external = ['http:', 'https:'].includes(new URL(safeHref).protocol) + return ( + + {children} + + ) + }, + img: ({ alt = '' }) => {alt}, + table: ({ children }) => ( +
+ {children}
+
+ ), // Fenced blocks route through the shared CodeBlock (shiki for registered // grammars, identical-geometry plain fallback for unknown/absent // languages); inline code keeps the default path (the :not(pre) @@ -53,7 +53,9 @@ function buildComponents(streaming: boolean): Components { // plain arm — retokenizing a growing fence on every chunk is quadratic // main-thread work; the finalize swap highlights it once. pre: ({ children }) => { - /* v8 ignore next 2 -- the markdown pipeline always hands `pre` its single `code` element; the undefined arm guards a react-markdown representation change. */ + // The markdown pipeline always hands `pre` its single `code` element; + // the undefined arm guards a react-markdown representation change. + /* v8 ignore next 2 */ const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined const raw = child?.props.children // A fence whose content isn't one plain string (e.g. an empty fence) diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx index a58248afab..47b0ad24fb 100644 --- a/packages/client/ui-primitives/tests/code-block.spec.tsx +++ b/packages/client/ui-primitives/tests/code-block.spec.tsx @@ -5,14 +5,17 @@ // display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx // alongside the rest of the markdown family. -import { describe, expect, it } from 'vitest' -import { cleanup, render } from '@testing-library/react' -import { afterEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { CodeBlock } from '../src/markdown/CodeBlock.tsx' import { highlightToHtml } from '../src/markdown/highlight.ts' afterEach(cleanup) +beforeEach(() => { + vi.useRealTimers() +}) + describe('highlightToHtml', () => { it('highlights a registered grammar into css-variables token spans', () => { const html = highlightToHtml('const x: number = 1', 'typescript') @@ -50,4 +53,86 @@ describe('CodeBlock', () => { expect(view.container.querySelector('pre.shiki')).toBeNull() expect(view.getByText('plain text')).toBeTruthy() }) + + it('shows the language banner and copies the pre textContent', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + render() + expect(screen.getByText('ts')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('const a = 1') + // Flush the clipboard promise under fake timers before asserting the label. + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + // While the ok label is showing, further clicks are no-ops. + fireEvent.click(screen.getByRole('button', { name: '复制成功' })) + expect(writeText).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1000) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('does not claim success when clipboard.writeText rejects', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull() + }) + + it('falls back to execCommand when clipboard.writeText is unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }) + const exec = vi.fn().mockReturnValue(true) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: exec, + }) + render() + fireEvent.click(screen.getByRole('button', { name: '复制' })) + expect(exec).toHaveBeenCalledWith('copy') + expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy() + }) + + it('does not claim success when execCommand throws or is absent', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }) + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: () => { + throw new Error('denied') + }, + }) + const denied = render() + fireEvent.click(denied.getByRole('button', { name: '复制' })) + await Promise.resolve() + expect(denied.getByRole('button', { name: '复制' })).toBeTruthy() + denied.unmount() + + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: undefined, + }) + const absent = render() + fireEvent.click(absent.getByRole('button', { name: '复制' })) + await Promise.resolve() + expect(absent.getByRole('button', { name: '复制' })).toBeTruthy() + expect(absent.queryByRole('button', { name: '复制成功' })).toBeNull() + }) }) diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index c7a95f49fb..ce599c0258 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -13,7 +13,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number wrapper.getBoundingClientRect = () => ({ top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34, width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}), - } as DOMRect) + }) } function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 281124b8d5..86cf99e49b 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -18,7 +18,7 @@ describe('ic_ds_ icon set', () => { expect(iconNames.length).toBe(55) }) - it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => { + it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { const Icon = icons[name]! const { container } = render() const svg = container.querySelector('svg') diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 05c7ce0139..b7f665c78a 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -57,8 +57,10 @@ describe('MarkdownText', () => { expect(container.querySelector('table')?.textContent).toContain('alphabeta') expect(container.querySelector('hr')).not.toBeNull() expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42') - // The ts fence routed through the shared CodeBlock: shiki token spans present. + // The ts fence routed through the shared CodeBlock: shiki token spans + banner. expect(container.querySelector('pre.shiki')).not.toBeNull() + expect(screen.getByText('ts')).toBeTruthy() + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() expect(container.querySelector('br')).not.toBeNull() expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank') expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() @@ -151,7 +153,7 @@ describe('JsonBlock', () => { it('truncates beyond the size cap with a suffix note', () => { const big = 'x'.repeat(30_000) const { container } = render() - const body = container.querySelector('pre')!.textContent! + const body = container.querySelector('pre')!.textContent expect(body.length).toBeLessThan(30_000) expect(body).toContain('截断') }) diff --git a/packages/client/ui-primitives/tests/state-dot.spec.tsx b/packages/client/ui-primitives/tests/state-dot.spec.tsx index 0d2cf52ef2..a3759174ff 100644 --- a/packages/client/ui-primitives/tests/state-dot.spec.tsx +++ b/packages/client/ui-primitives/tests/state-dot.spec.tsx @@ -7,7 +7,7 @@ import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('StateDot', () => { - it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', state => { + it.each(['done', 'warning', 'ongoing', 'error'] as const)('renders state %s as data-state', (state) => { const { container } = render() const dot = container.firstElementChild as HTMLElement expect(dot.dataset['state']).toBe(state) diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css index 6c0c1b854c..19306bd552 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.module.css +++ b/packages/client/ui-question/src/client/QuestionComposer.module.css @@ -128,6 +128,11 @@ gap: 10px; width: 100%; min-height: 42px; + /* Rows are the scroll content, never the slack absorber: a shrinkable row + collapses to min-height while its wrapped copy keeps the taller + intrinsic height, and centered content then paints outside the row box — + over the title and the next row. Overflow belongs to .options. */ + flex-shrink: 0; padding: 5px 8px; border: 1px solid transparent; border-radius: 12px; @@ -208,6 +213,9 @@ } .custom { + /* Same reason as .option: the custom block is scroll content, and shrinking + it pushes its trigger row (and the open textarea) past the footer. */ + flex-shrink: 0; border: 1px solid transparent; border-radius: 12px; } diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index 3571263f61..4380ae8db0 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -37,6 +37,8 @@ export function parseQuestionTitle(title: string): string { /** Return whether a textarea key event belongs to an active IME composition. */ function isComposing(event: KeyboardEvent): boolean { + // keyCode 229 is the legacy IME-composition signal engines emit without isComposing. + // eslint-disable-next-line @typescript-eslint/no-deprecated return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229 } @@ -61,7 +63,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { }))) const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null) const [error, setError] = useState(null) + // index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const question = questions[index]! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 @@ -145,10 +150,10 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) { const skipQuestion = (): void => { const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index ? { - selected: [], custom: '', - customOpen: (question.options?.length ?? 0) === 0, - skipped: true, - } + selected: [], custom: '', + customOpen: (question.options?.length ?? 0) === 0, + skipped: true, + } : item) setDrafts(nextDrafts) setError(null) diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index 2bc9289cef..dd130d80e2 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -50,7 +50,7 @@ const QUESTIONS = [ /** Carrier fixture: a real PendingWait over a scripted respond carrier. */ function wait(rpcId = 'question-1', respond = vi.fn(() => Promise.resolve({ accepted: true }))) { const carrier = new PendingWait( - 'question', RpcId(rpcId), SID, { questions: QUESTIONS } as PendingWait<'question'>['payload'], respond) + 'question', RpcId(rpcId), SID, { questions: QUESTIONS }, respond) return { carrier, respond } } @@ -99,7 +99,7 @@ describe('QuestionComposer', () => { { id: 'detail', selected: [], custom: '要能独立排查线上问题' }, { id: 'signals', selected: ['系统设计', '代码质量'] }, ])) - expect((screen.getByRole('button', { name: '正在提交…' }) as HTMLButtonElement).disabled).toBe(true) + expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) it('skips individual questions without discarding earlier answers', () => { @@ -173,7 +173,7 @@ describe('QuestionComposer', () => { // Receipt rejection surfaces through the domain face's thrown message. fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('question cancellation rejected: bad-response')).toBeTruthy() - expect((screen.getByRole('button', { name: '跳过本题' }) as HTMLButtonElement).disabled).toBe(false) + expect(screen.getByRole('button', { name: '跳过本题' }).disabled).toBe(false) fireEvent.click(screen.getByRole('button', { name: '放弃整组问题' })) expect(await screen.findByText('第二次取消失败')).toBeTruthy() @@ -199,7 +199,7 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) expect(await screen.findByText('网络中断')).toBeTruthy() - expect((screen.getByRole('button', { name: '提交' }) as HTMLButtonElement).disabled).toBe(false) + expect(screen.getByRole('button', { name: '提交' }).disabled).toBe(false) fireEvent.click(screen.getByRole('button', { name: '提交' })) expect(await screen.findByText('字符串错误')).toBeTruthy() diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 2a041c6cf4..f4395b4fba 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -49,7 +49,7 @@ describe('GeneralSection', () => { mount() expect(screen.getByText('Permission')).toBeTruthy() expect(screen.getByText('Choose default permission mode')).toBeTruthy() - const selector = screen.getByRole('button', { name: /Read only/ }) as HTMLButtonElement + const selector = screen.getByRole('button', { name: /Read only/ }) expect(selector.disabled).toBe(true) }) diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 04fa39a03c..0d12135311 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -34,7 +34,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) { // Local selection; entries can unmount underneath it, so the render-time // projection falls back to the first row when the id is gone. const [activeId, setActiveId] = useState(undefined) - const active = rows.find((r) => r.id === activeId)?.id ?? rows[0]?.id + const active = rows.find(r => r.id === activeId)?.id ?? rows[0]?.id const titleId = useId() useEffect(() => { @@ -56,7 +56,7 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {